diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php index 5f4cda5e92..f12123848d 100644 --- a/.php-cs-fixer.php +++ b/.php-cs-fixer.php @@ -117,6 +117,9 @@ ->exclude('src/testbench/workbench/storage') ->exclude('vendor') ->notPath('#^bin/#') + // These deliberately omit strict_types so PHP applies its native weak scalar conversion. + ->notPath('src/container/src/NativeInvoker.php') + ->notPath('src/data/src/Support/Creation/NativeScalar.php') ->notPath('tests/Data/Fixtures/PhpDocTypeContext.php') ->notPath('tests/Foundation/Fixtures/fake-compiled-view.php') ->name('hypervel-test-profile') diff --git a/docs/todo.md b/docs/todo.md index 5ad53efe51..aede6ada8e 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -70,6 +70,7 @@ ## Validation +- Remove pipe-separated validation rule strings in favor of arrays of rules across both `hypervel/validation` and `hypervel/data`, including Data's rule normalizer, denormalizer and `#[Rule]` handling. Update callers, tests and documentation together; preserve pipes inside individual regex patterns. - Adopt Brick Math 0.20's bounded parsing API for untrusted numeric validation inputs once the supported dependency graph permits it. Ramsey UUID currently limits Brick Math to 0.18, which has neither bounded parsing nor 0.20's parser-backtracking fix, so an intermediate upgrade would not address this issue. Preserve the configurable exponent-range behavior and cover delegated and compiled rules, oversized mantissas and exponents, malformed input, and `multiple_of`; do not add a local parsing workaround or disguise an incompatible Brick version with a Composer alias. ## Notifications diff --git a/docs/upstream-sync/sync.yaml b/docs/upstream-sync/sync.yaml index 516e1e4f2e..ab1a137fab 100644 --- a/docs/upstream-sync/sync.yaml +++ b/docs/upstream-sync/sync.yaml @@ -126,23 +126,23 @@ laravel/tinker: laravel/wayfinder: branch: main - checked_through: null - last_reviewed_pr: null - sync_date: null + checked_through: dd454ed0a7fba5debbcbd8f3da06a2b4a7fae6b8 + last_reviewed_pr: 295 + sync_date: '2026-10-03' notes: Track main for applicable fixes. Wayfinder next and its Surveyor, Ranger and Structure Discoverer dependency ports are deferred until Wayfinder v1. At each sync, check whether v1 has been released; if so, notify the owner and obtain approval before changing the tracked branch or beginning those ports. laravel/docs: branch: 13.x - checked_through: null - last_reviewed_pr: null - sync_date: null + checked_through: 156fc7fde114548640e13c39aa79b991291c3f91 + last_reviewed_pr: 11395 + sync_date: '2026-10-03' notes: Shared documentation for the framework and Laravel packages. Reconcile upstream changes with existing Hypervel documentation, including independently written coverage. Replace custom wording for equivalent content with upstream wording, adapting it for Hypervel and preserving enhancements and intentional differences absent upstream. Do not duplicate existing coverage. inertiajs/inertia-laravel: branch: 3.x - checked_through: null - last_reviewed_pr: null - sync_date: null + checked_through: 4da52b72da39396cad1d3ec8523b6c649009456e + last_reviewed_pr: 918 + sync_date: '2026-10-03' inertiajs/docs: branch: main @@ -153,9 +153,10 @@ inertiajs/docs: spatie/laravel-data: branch: main - checked_through: null + checked_through: ce296f22861dc1237ce468754cc7f46d3ac34ad5 last_reviewed_pr: null - sync_date: null + sync_date: '2026-10-03' + notes: Hypervel replaces upstream's data pipes and resolvers with a fixed engine. The pipes and DataFromSomethingResolver map to Support/Creation/DataCreator and its per-operation ConstructionState; the validation resolvers and built-in rule inferrers map to Support/Validation/DataValidationCompiler and DataValidator; TransformedDataResolver and VisibleDataFieldsResolver map to Support/Transformation/DataTransformer and PartialTree. DataCreator's direct construction from DataClass creation recipes and DataTransformer's bulk copy are deliberate fast paths, so apply upstream behavior changes to them as well as to the general paths, and compare tests/Benchmarks/Data/benchmark.php results before and after. Upstream tests for those replaced classes live in the matching upstream feature tests or in the owning engine's tests under tests/Data/Support. spatie/laravel-permission: branch: main diff --git a/src/container/src/Attributes/Give.php b/src/container/src/Attributes/Give.php index a61da3e634..cdfeb062cf 100644 --- a/src/container/src/Attributes/Give.php +++ b/src/container/src/Attributes/Give.php @@ -5,18 +5,25 @@ namespace Hypervel\Container\Attributes; use Attribute; +use Hypervel\Container\Attributes\Concerns\ExtractsPropertyValue; use Hypervel\Contracts\Container\Container; use Hypervel\Contracts\Container\ContextualAttribute; #[Attribute(Attribute::TARGET_PARAMETER)] class Give implements ContextualAttribute { + use ExtractsPropertyValue; + /** * Provide a concrete class implementation for dependency injection. + * + * Property paths use data_get() and may invoke object accessors or lazy-load + * Eloquent relationships. */ public function __construct( public string $class, public array $params = [], + public ?string $property = null, ) { } @@ -25,6 +32,9 @@ public function __construct( */ public static function resolve(self $attribute, Container $container): mixed { - return $container->make($attribute->class, $attribute->params); + return $attribute->extractPropertyValue( + $container->make($attribute->class, $attribute->params), + $attribute->property, + ); } } diff --git a/src/container/src/BoundMethod.php b/src/container/src/BoundMethod.php index e53c7a4215..32f9d3298f 100644 --- a/src/container/src/BoundMethod.php +++ b/src/container/src/BoundMethod.php @@ -56,7 +56,11 @@ public static function call(Container $container, callable|string $callback, arr } return static::callBoundMethod($container, $callback, function () use ($container, $callback, $parameters, $reflection) { - return $callback(...array_values(static::getMethodDependencies($container, $callback, $parameters, $reflection))); + // A function name is resolved before its dependencies, so a missing one fails as a direct call would. + return NativeInvoker::call( + is_string($callback) ? $callback(...) : $callback, + array_values(static::getMethodDependencies($container, $callback, $parameters, $reflection)), + ); }); } diff --git a/src/container/src/Container.php b/src/container/src/Container.php index ed08474300..332240ddf2 100755 --- a/src/container/src/Container.php +++ b/src/container/src/Container.php @@ -1756,6 +1756,55 @@ public function buildWith(Closure|string $concrete, array $parameters = []): mix } } + /** + * Resolve the contextual constructor parameters of a class within its build context. + * + * Contextual bindings for the class apply to dependencies the attributes resolve, and each + * parameter's attribute callbacks fire here. Pass the values to buildWith() as overrides + * so they are not resolved again. Variadic parameters are skipped, because an override + * is passed as one argument; the container resolves them when the class is built. + * + * @param class-string $concrete + * @param null|list $names the parameters to resolve, or null for every contextual parameter + * @return array + * + * @throws BindingResolutionException + */ + public function resolveContextualParameters(string $concrete, ?array $names = null): array + { + $recipe = $this->getBuildRecipe($concrete); + $resolutionState = $this->getOrCreateResolutionState(); + $resolutionState->buildStack[] = $concrete; + + try { + $values = []; + + foreach ($recipe->parameters as $paramRecipe) { + if ($paramRecipe->contextualAttribute === null + || $paramRecipe->isVariadic + || ($names !== null && ! in_array($paramRecipe->name, $names, true)) + ) { + continue; + } + + $value = $this->resolveFromAttribute( + $paramRecipe->contextualAttribute, + $paramRecipe->getReflectionParameter(), + ); + + if ($paramRecipe->attributes !== []) { + $this->fireAfterResolvingAttributeCallbacks($paramRecipe->attributes, $value); + } + + $values[$paramRecipe->name] = $value; + } + + return $values; + } finally { + array_pop($resolutionState->buildStack); + } + } + /** * Instantiate a concrete instance of the given type. * @@ -1824,7 +1873,10 @@ public function build(Closure|string $concrete): mixed array_pop($resolutionState->buildStack); } - $instance = new $concrete(...$instances); + // Arguments go through the native invoker so scalars convert as they do in Laravel. + $instance = $instances === [] + ? new $concrete + : NativeInvoker::construct($concrete, $instances); if ($recipe->classAttributes !== []) { $this->fireAfterResolvingAttributeCallbacks( diff --git a/src/container/src/NativeInvoker.php b/src/container/src/NativeInvoker.php new file mode 100644 index 0000000000..fd5568ef69 --- /dev/null +++ b/src/container/src/NativeInvoker.php @@ -0,0 +1,38 @@ + $arguments + */ + public static function call(callable $callback, array $arguments): mixed + { + return $callback(...$arguments); + } + + /** + * Construct a class with PHP's weak scalar conversion. + * + * @template TClass of object + * + * @param class-string $class + * @param list $arguments + * @return TClass + */ + public static function construct(string $class, array $arguments): object + { + return new $class(...$arguments); + } +} diff --git a/src/contracts/src/Container/Container.php b/src/contracts/src/Container/Container.php index d5271e04d8..7f5b05feca 100644 --- a/src/contracts/src/Container/Container.php +++ b/src/contracts/src/Container/Container.php @@ -169,6 +169,21 @@ public function build(Closure|string $concrete): mixed; */ public function buildWith(Closure|string $concrete, array $parameters = []): mixed; + /** + * Resolve the contextual constructor parameters of a class within its build context. + * + * Pass the values to buildWith() as overrides so they are not resolved again. Variadic + * parameters are skipped, because an override is passed as one argument; the container + * resolves them when the class is built. + * + * @param class-string $concrete + * @param null|list $names the parameters to resolve, or null for every contextual parameter + * @return array + * + * @throws BindingResolutionException + */ + public function resolveContextualParameters(string $concrete, ?array $names = null): array; + /** * Call the given Closure / class@method and inject its dependencies. */ diff --git a/src/data/README.md b/src/data/README.md index 1b88fc97a4..b2f0620971 100644 --- a/src/data/README.md +++ b/src/data/README.md @@ -4,20 +4,54 @@ Documentation: https://hypervel.org/docs/data-objects ## Differences From Laravel -`Data`, `Dto`, and `Resource` use `OnlyRequests` validation by default, and Hypervel retains the class-level `withValidator()` hook. The `validate()` method ignores named factories and returns validated input. The `validateAndCreate()` method may use a named factory that returns the finished object, in which case that factory is responsible for validation. Each call to `factory()` returns a new factory. +`Resource` authorizes and validates request input under the default `OnlyRequests` strategy, as `Data` and `Dto` do, so request input is validated whichever base class receives it; Spatie's `Resource` never validates. When a named factory accepts a request, Hypervel authorizes the request but does not validate it first, so a factory that returns the finished object is responsible for validation. -Omitted nullable properties become `null`; use `Optional` to preserve absence and `#[Present]` when a nullable key must be supplied. A Model attribute containing `null` remains an explicit value, even for a non-nullable property with a default. With multiple payloads, the first source containing a property's input key wins, including when the value is `null`. +With multiple payloads, Hypervel validates the combined input once rather than a request payload on its own, so the rules check the values the object will receive. Later payloads take precedence, and a later explicit `null` replaces an earlier value where Spatie keeps the earlier value, so a later payload can clear a value. An `Optional` value never replaces an earlier value. -Hypervel rejects data classes with conflicting input or output mappings when the class is first used. Uniform nested collections use wildcard validation rules, while collections with different item shapes or rules use exact indexed rules. +The `casts`, `transformers`, `normalizers`, and `rule_inferrers` options only hold your own extensions. Built-in source normalization, casting, transformation, and rule inference are fixed and need no configuration, so Spatie's default normalizer and built-in rule inferrer classes are not included. Its optional `FormRequestNormalizer` is included. Typed iterable items are always cast and transformed, and an array given to a collection property becomes that collection, as with Spatie's `cast_and_transform_iterables` feature. Spatie's published configuration turns that feature off; Hypervel has no option for it. Scalar items follow PHP's weak typing, so an item PHP cannot convert, such as `'abc'` for an `int`, fails with a `TypeError` where Spatie's item cast turns it into `0`. -Constructor injection uses Hypervel contextual attributes, including property extraction through `CurrentUser` and `RouteParameter`. Their resolved value always wins over payload input, including `null`; use a named factory or creation hook when payload values should take precedence. +Responses use the `200` status code for every request method. Spatie returns `201 Created` for every `POST` request, including searches and other requests that create nothing, and provides a `calculateResponseStatus()` override. Set `201` in `withResponse()` when the request actually created something, as Laravel's API resources do for newly created models. + +Hypervel rejects data classes with conflicting input or output mappings when the class is first used, rather than letting one value silently overwrite another. An `int` property infers the `integer` rule where Spatie infers `numeric`, so a fractional value fails validation instead of being truncated. Uniform nested collections use wildcard validation rules, while collections with different item shapes or rules use exact indexed rules, so each item is checked against the rules for its own shape. A required collection of data objects must be present but may be empty, as in Spatie; a nullable one may be omitted and becomes `null`, where Spatie still requires its key. + +When a union property already accepts a value, Hypervel keeps it instead of converting it to another declared type: a string given to `string|SongData` stays a string, and an array given to `array|Collection` stays an array. Spatie first tries to build the data object and keeps the original value only if that throws, which also hides genuine construction failures; it also converts an array to the collection type of a container union. Explicit casts still run first, the declared item type of the kept container is still cast, and validation uses the rules of the type that holds the value, where Spatie requires an array for any union with a data type. Declare `Collection` alone when the property should always hold a collection. In a union with more than one container type, a value that several of them accept, or that no declared type accepts, is rejected rather than guessed. See [type conversion](https://hypervel.org/docs/data-objects#type-conversion). + +A model attribute holding `null` is passed as `null`, so it fails for a property that does not accept `null`. Spatie treats it as missing and substitutes the property's default or `Optional`, so the object no longer matches the stored data. Columns that were not selected are treated as missing. + +An Eloquent cast to an abstract data class only reads a stored alias or class name that resolves to a concrete subtype of that class; Spatie creates whatever class the stored value names. Data casts encode stored values with Eloquent's JSON codec, so a collection class's own `toJson()`, such as one that pretty-prints, doesn't change what is stored as it does in Spatie. + +A required property declared outside the constructor that receives no input, and no value from its default or the constructor, fails creation with `CannotCreateData`. Spatie leaves it uninitialized, so the error only appears when the property is read. Nullable and `Optional` properties still receive `null` or `Optional`. + +Constructor injection uses Hypervel contextual attributes, including property extraction through `CurrentUser` and `RouteParameter`. Their resolved value always wins over payload input and creation hooks, including `null`, so client input cannot replace a server-resolved value such as the current user. When input should take precedence, use a named factory that returns the finished object, or remove the contextual attribute and supply the value through a creation hook. + +Spatie's configurable `pipeline()` and custom `DataPipe` classes are not included. Hypervel uses fixed creation phases over shared per-operation state, with metadata cached for the worker lifetime. Validation and construction use the same prepared values and recorded type decisions, preserving consistency while supporting optimized execution. Configurable pipeline ordering would undermine those guarantees. Use [named factories](https://hypervel.org/docs/data-objects#named-factories), [`prepareForPipeline()`](https://hypervel.org/docs/data-objects#preparing-input), or [factory hooks](https://hypervel.org/docs/data-objects#creation-factories). + +Spatie's `ContextableData` and `getDataContext()` are not included. An object's partial selections are available from `getPartialsDefinition()` and its wrapping from `getWrap()`. + +Looping over a data collection copies its partial selections onto the items, as in Spatie, but doesn't consume the collection's temporary selections; only the collection's own transformation does. Spatie's loop consumes them from the collection, leaving only the copies on its items. See [including and excluding properties](https://hypervel.org/docs/data-objects#including-and-excluding-properties). + +A malformed partial path given in code, such as `''`, `*.name` or `{name, age}.name`, throws when the object is transformed. Spatie ignores an empty path and anything after a `*` or a group, so `*.name` includes every lazy property at every depth. Malformed paths in the request query string are skipped. + +A custom cast's `$properties` argument holds the object's declared property values keyed by PHP property name. Unlike Spatie's, it excludes undeclared input, raw input names, and contextual and computed values. See [custom casts](https://hypervel.org/docs/data-objects#custom-casts). + +Casts and named methods receive one `CreationContext` for the whole creation, and it does not change while nested objects are created. Its `dataClass` is the class the creation started with, where Spatie updates it to the nested class being created. It has no `from()`, `collect()`, or `currentPath`; pass it to the target class's `factory()` to create another object with the same options. + +A few members of the `DataProperty`, `DataClass`, and `TransformationContext` metadata that casts, transformers, and other extensions read differ from Spatie's, such as `DataProperty` having no `defaultValue` because defaults are read from reflection when needed. `TransformationContext` is a final immutable class, so it can't be subclassed to carry extra state into a transformation; give a transformer instance its own inputs and pass it to `withTransformer()` instead. See the [porting guide](https://hypervel.org/docs/porting-from-laravel#data-objects). + +`make:data` takes the complete namespace through Hypervel's `--target-namespace` generator option instead of Spatie's `--namespace` option. Spatie's `data:cache-structures` command is not included; Hypervel does not require a structure cache step during deployment. -Spatie's data-specific `From*` attributes, `withOptionalValues()`, `withoutOptionalValues()`, `SerializeTransformer`, and `UnserializeCast` are not included. Use Hypervel's contextual attributes, declared `Optional` unions, native PHP serialization, or an explicit custom cast or transformer. +Spatie's data-specific `From*` attributes are not included. Use Hypervel's contextual attributes. + +With `withoutOptionalValues()`, a missing `Optional` property that cannot hold `null` keeps `Optional`, where Spatie leaves it uninitialized. A missing nullable `Optional` property receives `null`, as in Spatie. + +Spatie's `UnserializeCast` is not included because it unserializes property input, which may come from a request, without restricting the classes it creates. Write a custom cast that passes `allowed_classes` to `unserialize()` when a property holds trusted serialized values. Named `collect*` methods receive the source's own array, collection, or paginator shape after its values have been converted to data objects, rather than the original source values. An Eloquent collection source is provided as a base `Hypervel\Support\Collection`. When you pass an explicit `$into` target, the method's declared return type must also match that target. +Collecting an array or collection into a paginator target, or giving one to a paginator property, is rejected because it has no pagination details to keep. For an array, Spatie creates a first page that uses the item count as the total and 15 items per page. + Deprecated collection proxy methods, Livewire integration, and TypeScript generation are not included. Use `toCollection()` for collection operations. TypeScript generation belongs in a general transformer package. Ported from: https://github.com/spatie/laravel-data diff --git a/src/data/config/data.php b/src/data/config/data.php index fb55f2d751..2c39234e8b 100644 --- a/src/data/config/data.php +++ b/src/data/config/data.php @@ -19,6 +19,15 @@ */ 'date_timezone' => null, + 'features' => [ + /* + * When trying to set a computed property value, the package will throw an exception. + * You can disable this behavior by setting this option to true, which will then just + * ignore the value being passed into the computed property and recalculate it. + */ + 'ignore_exception_when_trying_to_set_computed_property_value' => false, + ], + /* * Custom global transformers override the package's fixed transformation for * their declared types. @@ -31,11 +40,20 @@ */ 'casts' => [], + /* + * Rule inferrers adjust the validation rules of every data property after + * the package's fixed rule inference. Only add your own inferrers here. + */ + 'rule_inferrers' => [], + /* * Custom global normalizers run after normalizers declared by the data class - * and before the package's fixed source normalization. + * and before the package's fixed source normalization. The optional form + * request normalizer reads only a form request's validated input. */ - 'normalizers' => [], + 'normalizers' => [ + // Hypervel\Data\Normalizers\FormRequestNormalizer::class, + ], /* * Data objects can be wrapped into a key like 'data' when used as a resource, @@ -44,10 +62,18 @@ */ 'wrap' => null, + /* + * Adds a specific caster to the Symfony VarDumper component which hides + * some properties from data objects and collections when being dumped + * by `dump` or `dd`. Can be 'enabled', 'disabled' or 'development' + * which will only enable the caster locally. + */ + 'var_dumper_caster_mode' => 'development', + /* * A data object can be validated when created using a factory or when calling the from * method. By default, only when a request is passed the data is being validated. This - * behaviour can be changed to always validate or to completely disable validation. + * behavior can be changed to always validate or to completely disable validation. */ 'validation_strategy' => ValidationStrategy::OnlyRequests->value, @@ -61,6 +87,13 @@ 'output' => null, ], + /* + * When a nested include, exclude, only or except partial targets a property that does + * not exist, the package will throw an exception. You can disable this behavior by + * setting this option to true. + */ + 'ignore_invalid_partials' => false, + /* * When transforming a nested chain of data objects, the package can end up in an infinite * loop when including a recursive relationship. The max transformation depth can be @@ -68,4 +101,38 @@ * package will not enforce a maximum depth. */ 'max_transformation_depth' => null, + + /* + * When the maximum transformation depth is reached, the package will throw an exception. + * You can disable this behavior by setting this option to false which will return an + * empty array. Values stored by Eloquent casts always throw instead of being truncated. + */ + 'throw_when_max_transformation_depth_reached' => true, + + /* + * When using the `make:data` command, the package will use these settings to generate + * the data classes. You can override these settings by passing options to the command. + */ + 'commands' => [ + /* + * Provides default configuration for the `make:data` command. These settings can be overridden with options + * passed directly to the `make:data` command for generating single Data classes, or if not set they will + * automatically fall back to these defaults. See `php artisan make:data --help` for more information + */ + 'make' => [ + /* + * The default namespace for generated Data classes. This exists under the application's root namespace, + * so the default 'Data' will end up as '\App\Data', and generated Data classes will be placed in the + * app/Data/ folder. Data classes can live anywhere, but this is where `make:data` will put them. + */ + 'namespace' => 'Data', + + /* + * This suffix will be appended to all data classes generated by make:data, so that they are less likely + * to conflict with other related classes, controllers or models with a similar name without resorting + * to adding an alias for the Data object. Set to a blank string (not null) to disable. + */ + 'suffix' => 'Data', + ], + ], ]; diff --git a/src/data/src/Attributes/Validation/AcceptedIf.php b/src/data/src/Attributes/Validation/AcceptedIf.php index a7fedf3662..af08c19ca3 100644 --- a/src/data/src/Attributes/Validation/AcceptedIf.php +++ b/src/data/src/Attributes/Validation/AcceptedIf.php @@ -43,14 +43,6 @@ public function parameters(): array ]; } - /** - * Create the attribute from parsed string parameters. - */ - public static function create(string ...$parameters): static - { - return parent::create( - $parameters[0], - self::parseBooleanValue($parameters[1]), - ); - } + // The inherited create() keeps the parsed value as written: converting '1' to 'true', as Spatie does, + // changes what a string or integer dependent matches. } diff --git a/src/data/src/Attributes/Validation/After.php b/src/data/src/Attributes/Validation/After.php index 6d3e923f1d..c4b14c1cd3 100644 --- a/src/data/src/Attributes/Validation/After.php +++ b/src/data/src/Attributes/Validation/After.php @@ -35,13 +35,5 @@ public function parameters(): array return [$this->date]; } - /** - * Create the attribute from parsed string parameters. - */ - public static function create(string ...$parameters): static - { - return parent::create( - self::parseDateValue($parameters[0]), - ); - } + // The inherited create() keeps the parsed date or field name as written, which the validator resolves. } diff --git a/src/data/src/Attributes/Validation/AfterOrEqual.php b/src/data/src/Attributes/Validation/AfterOrEqual.php index 9200c55c6b..ff5f99eb7f 100644 --- a/src/data/src/Attributes/Validation/AfterOrEqual.php +++ b/src/data/src/Attributes/Validation/AfterOrEqual.php @@ -35,13 +35,5 @@ public function parameters(): array return [$this->date]; } - /** - * Create the attribute from parsed string parameters. - */ - public static function create(string ...$parameters): static - { - return parent::create( - self::parseDateValue($parameters[0]), - ); - } + // The inherited create() keeps the parsed date or field name as written, which the validator resolves. } diff --git a/src/data/src/Attributes/Validation/Before.php b/src/data/src/Attributes/Validation/Before.php index 1106c2f3d0..ed636a4cb6 100644 --- a/src/data/src/Attributes/Validation/Before.php +++ b/src/data/src/Attributes/Validation/Before.php @@ -35,13 +35,5 @@ public function parameters(): array return [$this->date]; } - /** - * Create the attribute from parsed string parameters. - */ - public static function create(string ...$parameters): static - { - return parent::create( - self::parseDateValue($parameters[0]), - ); - } + // The inherited create() keeps the parsed date or field name as written, which the validator resolves. } diff --git a/src/data/src/Attributes/Validation/BeforeOrEqual.php b/src/data/src/Attributes/Validation/BeforeOrEqual.php index fe2858c6ca..6f6fd3dfde 100644 --- a/src/data/src/Attributes/Validation/BeforeOrEqual.php +++ b/src/data/src/Attributes/Validation/BeforeOrEqual.php @@ -35,13 +35,5 @@ public function parameters(): array return [$this->date]; } - /** - * Create the attribute from parsed string parameters. - */ - public static function create(string ...$parameters): static - { - return parent::create( - self::parseDateValue($parameters[0]), - ); - } + // The inherited create() keeps the parsed date or field name as written, which the validator resolves. } diff --git a/src/data/src/Attributes/Validation/DateEquals.php b/src/data/src/Attributes/Validation/DateEquals.php index 6f063b10f4..43c126e474 100644 --- a/src/data/src/Attributes/Validation/DateEquals.php +++ b/src/data/src/Attributes/Validation/DateEquals.php @@ -34,13 +34,5 @@ public function parameters(): array return [$this->date]; } - /** - * Create the attribute from parsed string parameters. - */ - public static function create(string ...$parameters): static - { - return parent::create( - self::parseDateValue($parameters[0]), - ); - } + // The inherited create() keeps the parsed date or field name as written, which the validator resolves. } diff --git a/src/data/src/Attributes/Validation/DeclinedIf.php b/src/data/src/Attributes/Validation/DeclinedIf.php index d576ac59d6..7ddd7d89ed 100644 --- a/src/data/src/Attributes/Validation/DeclinedIf.php +++ b/src/data/src/Attributes/Validation/DeclinedIf.php @@ -43,14 +43,6 @@ public function parameters(): array ]; } - /** - * Create the attribute from parsed string parameters. - */ - public static function create(string ...$parameters): static - { - return parent::create( - $parameters[0], - self::parseBooleanValue($parameters[1]), - ); - } + // The inherited create() keeps the parsed value as written: converting '1' to 'true', as Spatie does, + // changes what a string or integer dependent matches. } diff --git a/src/data/src/Attributes/Validation/Dimensions.php b/src/data/src/Attributes/Validation/Dimensions.php index 07d33bac25..afc661e6ee 100644 --- a/src/data/src/Attributes/Validation/Dimensions.php +++ b/src/data/src/Attributes/Validation/Dimensions.php @@ -104,10 +104,20 @@ public static function keyword(): string */ public static function create(string ...$parameters): static { - $parameters = collect($parameters)->mapWithKeys(function (string $parameter) { + $parameters = collect($parameters)->mapWithKeys(function (string $parameter): array { return [Str::camel(Str::before($parameter, '=')) => Str::after($parameter, '=')]; })->all(); + // Native constraints without an attribute parameter, such as min_ratio, stay in the original rule string. + $unsupported = array_diff( + array_keys($parameters), + ['minWidth', 'minHeight', 'maxWidth', 'maxHeight', 'ratio', 'width', 'height'], + ); + + if ($unsupported !== []) { + throw CannotBuildValidationRule::create('Could not make a dimensions rule with [' . implode(', ', $unsupported) . '].'); + } + return new static(...$parameters); } } diff --git a/src/data/src/Attributes/Validation/ExcludeIf.php b/src/data/src/Attributes/Validation/ExcludeIf.php index 0a539acf2d..ea2e696117 100644 --- a/src/data/src/Attributes/Validation/ExcludeIf.php +++ b/src/data/src/Attributes/Validation/ExcludeIf.php @@ -32,16 +32,8 @@ public static function keyword(): string return 'exclude_if'; } - /** - * Create the attribute from parsed string parameters. - */ - public static function create(string ...$parameters): static - { - return parent::create( - $parameters[0], - self::parseBooleanValue($parameters[1]), - ); - } + // The inherited create() keeps the parsed value as written: converting '1' to 'true', as Spatie does, + // changes what a string or integer dependent matches. /** * Get the rule parameters. diff --git a/src/data/src/Attributes/Validation/Exists.php b/src/data/src/Attributes/Validation/Exists.php index 91b8f358d5..3386f1fa01 100644 --- a/src/data/src/Attributes/Validation/Exists.php +++ b/src/data/src/Attributes/Validation/Exists.php @@ -101,6 +101,11 @@ public static function keyword(): string */ public static function create(string ...$parameters): static { + // Additional where conditions stay in the original rule string. + if (count($parameters) > 2) { + throw CannotBuildValidationRule::create('Could not make an exists rule from more than a table and column.'); + } + return new static(rule: new BaseExists($parameters[0], $parameters[1] ?? 'NULL')); } } diff --git a/src/data/src/Attributes/Validation/Rule.php b/src/data/src/Attributes/Validation/Rule.php index 1fc5728e1d..be5cd94868 100644 --- a/src/data/src/Attributes/Validation/Rule.php +++ b/src/data/src/Attributes/Validation/Rule.php @@ -5,21 +5,28 @@ namespace Hypervel\Data\Attributes\Validation; use Attribute; +use Closure; +use Hypervel\Contracts\Validation\CompilableRules; use Hypervel\Contracts\Validation\Rule as RuleContract; use Hypervel\Contracts\Validation\ValidationRule as ValidationRuleContract; use Hypervel\Data\Support\Validation\ValidationRule; +use Hypervel\Validation\ConditionalRules; +use Stringable; #[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_PARAMETER)] class Rule extends ValidationRule { - /** @var array */ + /** @var array */ protected array $rules = []; /** * Create a custom rule attribute. + * + * Each rule takes any form the validator accepts, including closures, fluent rule objects and conditional rules. */ - public function __construct(string|array|ValidationRule|RuleContract|ValidationRuleContract ...$rules) - { + public function __construct( + string|array|Closure|Stringable|CompilableRules|ConditionalRules|ValidationRule|RuleContract|ValidationRuleContract ...$rules + ) { $this->rules = $rules; } diff --git a/src/data/src/Attributes/Validation/StringValidationAttribute.php b/src/data/src/Attributes/Validation/StringValidationAttribute.php index 850b3c9282..c9afc3445a 100644 --- a/src/data/src/Attributes/Validation/StringValidationAttribute.php +++ b/src/data/src/Attributes/Validation/StringValidationAttribute.php @@ -4,6 +4,9 @@ namespace Hypervel\Data\Attributes\Validation; +use Hypervel\Data\Exceptions\CannotBuildValidationRule; +use Hypervel\Support\ClassMetadataCache; + abstract class StringValidationAttribute extends ValidationAttribute { /** @@ -16,6 +19,17 @@ abstract public function parameters(): array; */ public static function create(string ...$parameters): static { + $constructor = ClassMetadataCache::reflectClass(static::class)->getConstructor(); + + // PHP ignores surplus arguments, so a parameter the attribute cannot hold would be lost. + if (! ($constructor?->isVariadic() ?? false) + && count($parameters) > ($constructor?->getNumberOfParameters() ?? 0) + ) { + throw CannotBuildValidationRule::create( + 'The [' . static::keyword() . '] attribute cannot hold ' . count($parameters) . ' parameters.', + ); + } + return new static(...$parameters); } } diff --git a/src/data/src/Attributes/Validation/Unique.php b/src/data/src/Attributes/Validation/Unique.php index 09f836bfd0..e6d179a548 100644 --- a/src/data/src/Attributes/Validation/Unique.php +++ b/src/data/src/Attributes/Validation/Unique.php @@ -113,6 +113,11 @@ public static function keyword(): string */ public static function create(string ...$parameters): static { + // An ignored id, id column and where conditions stay in the original rule string. + if (count($parameters) > 2) { + throw CannotBuildValidationRule::create('Could not make a unique rule from more than a table and column.'); + } + return new static(rule: new BaseUnique($parameters[0], $parameters[1] ?? 'NULL')); } } diff --git a/src/data/src/Attributes/Validation/ValidationAttribute.php b/src/data/src/Attributes/Validation/ValidationAttribute.php index 5138cf6ca4..998abfdfeb 100644 --- a/src/data/src/Attributes/Validation/ValidationAttribute.php +++ b/src/data/src/Attributes/Validation/ValidationAttribute.php @@ -9,7 +9,6 @@ use Hypervel\Data\Support\Validation\RuleDenormalizer; use Hypervel\Data\Support\Validation\ValidationPath; use Hypervel\Data\Support\Validation\ValidationRule; -use Hypervel\Support\CarbonImmutable; use Stringable; abstract class ValidationAttribute extends ValidationRule implements Stringable @@ -32,48 +31,6 @@ public function __toString(): string return implode('|', (new RuleDenormalizer)->execute($this, ValidationPath::create())); } - /** - * Parse a validation date value. - */ - protected static function parseDateValue(mixed $value): mixed - { - if (! is_string($value)) { - return $value; - } - - if ($value === 'tomorrow') { - return $value; - } - - $time = strtotime($value); - - if ($time === false) { - return $value; - } - - return CarbonImmutable::parse($time); - } - - /** - * Parse a validation boolean value. - */ - protected static function parseBooleanValue(mixed $value): mixed - { - if (! is_string($value)) { - return $value; - } - - if ($value === 'true' || $value === '1') { - return 'true'; - } - - if ($value === 'false' || $value === '0') { - return 'false'; - } - - return $value; - } - /** * Parse a field reference. */ diff --git a/src/data/src/Casts/BuiltinTypeCast.php b/src/data/src/Casts/BuiltinTypeCast.php index 32f139b298..517ee2150c 100644 --- a/src/data/src/Casts/BuiltinTypeCast.php +++ b/src/data/src/Casts/BuiltinTypeCast.php @@ -4,7 +4,6 @@ namespace Hypervel\Data\Casts; -use Hypervel\Data\Support\Creation\ConstructionState; use Hypervel\Data\Support\Creation\CreationContext; use Hypervel\Data\Support\Creation\ValueCaster; use Hypervel\Data\Support\DataProperty; @@ -27,7 +26,7 @@ public function __construct( public function cast( DataProperty $property, mixed $value, - ConstructionState $state, + array $properties, CreationContext $context, ): mixed { return $this->runCast($value); @@ -39,7 +38,7 @@ public function cast( public function castIterableItem( DataProperty $property, mixed $value, - ConstructionState $state, + array $properties, CreationContext $context, ): mixed { return $this->runCast($value); diff --git a/src/data/src/Casts/Cast.php b/src/data/src/Casts/Cast.php index 962d7f5300..2271dbf642 100644 --- a/src/data/src/Casts/Cast.php +++ b/src/data/src/Casts/Cast.php @@ -4,7 +4,6 @@ namespace Hypervel\Data\Casts; -use Hypervel\Data\Support\Creation\ConstructionState; use Hypervel\Data\Support\Creation\CreationContext; use Hypervel\Data\Support\DataProperty; @@ -13,11 +12,13 @@ interface Cast { /** * Cast a property value. + * + * @param array $properties the object's declared property values, keyed by property name */ public function cast( DataProperty $property, mixed $value, - ConstructionState $state, + array $properties, CreationContext $context, ): mixed; } diff --git a/src/data/src/Casts/CastableCast.php b/src/data/src/Casts/CastableCast.php index 30913e27e2..1e0dff3973 100644 --- a/src/data/src/Casts/CastableCast.php +++ b/src/data/src/Casts/CastableCast.php @@ -4,7 +4,6 @@ namespace Hypervel\Data\Casts; -use Hypervel\Data\Support\Creation\ConstructionState; use Hypervel\Data\Support\Creation\CreationContext; use Hypervel\Data\Support\DataProperty; @@ -30,11 +29,11 @@ public function __construct( public function cast( DataProperty $property, mixed $value, - ConstructionState $state, + array $properties, CreationContext $context, ): mixed { $this->cast ??= $this->castableClass::dataCastUsing($this->arguments); - return $this->cast->cast($property, $value, $state, $context); + return $this->cast->cast($property, $value, $properties, $context); } } diff --git a/src/data/src/Casts/DateTimeInterfaceCast.php b/src/data/src/Casts/DateTimeInterfaceCast.php index cc5900c8a0..f919d1701d 100644 --- a/src/data/src/Casts/DateTimeInterfaceCast.php +++ b/src/data/src/Casts/DateTimeInterfaceCast.php @@ -5,7 +5,6 @@ namespace Hypervel\Data\Casts; use DateTimeInterface; -use Hypervel\Data\Support\Creation\ConstructionState; use Hypervel\Data\Support\Creation\CreationContext; use Hypervel\Data\Support\Creation\ValueCaster; use Hypervel\Data\Support\DataProperty; @@ -32,7 +31,7 @@ public function __construct( public function cast( DataProperty $property, mixed $value, - ConstructionState $state, + array $properties, CreationContext $context, ): DateTimeInterface|Uncastable { return $this->castValue( @@ -48,7 +47,7 @@ public function cast( public function castIterableItem( DataProperty $property, mixed $value, - ConstructionState $state, + array $properties, CreationContext $context, ): DateTimeInterface|Uncastable { return $this->castValue( diff --git a/src/data/src/Casts/EnumCast.php b/src/data/src/Casts/EnumCast.php index fccbe6821d..ac94b0ce88 100644 --- a/src/data/src/Casts/EnumCast.php +++ b/src/data/src/Casts/EnumCast.php @@ -5,7 +5,6 @@ namespace Hypervel\Data\Casts; use BackedEnum; -use Hypervel\Data\Support\Creation\ConstructionState; use Hypervel\Data\Support\Creation\CreationContext; use Hypervel\Data\Support\Creation\ValueCaster; use Hypervel\Data\Support\DataProperty; @@ -28,7 +27,7 @@ public function __construct( public function cast( DataProperty $property, mixed $value, - ConstructionState $state, + array $properties, CreationContext $context, ): BackedEnum|Uncastable { return $this->castValue( @@ -44,7 +43,7 @@ public function cast( public function castIterableItem( DataProperty $property, mixed $value, - ConstructionState $state, + array $properties, CreationContext $context, ): BackedEnum|Uncastable { return $this->castValue( diff --git a/src/data/src/Casts/IterableItemCast.php b/src/data/src/Casts/IterableItemCast.php index fd23ab9d4b..cca095c7b1 100644 --- a/src/data/src/Casts/IterableItemCast.php +++ b/src/data/src/Casts/IterableItemCast.php @@ -4,7 +4,6 @@ namespace Hypervel\Data\Casts; -use Hypervel\Data\Support\Creation\ConstructionState; use Hypervel\Data\Support\Creation\CreationContext; use Hypervel\Data\Support\DataProperty; @@ -12,11 +11,13 @@ interface IterableItemCast { /** * Cast one item within an iterable property. + * + * @param array $properties the object's declared property values, keyed by property name */ public function castIterableItem( DataProperty $property, mixed $value, - ConstructionState $state, + array $properties, CreationContext $context, ): mixed; } diff --git a/src/data/src/Casts/Uncastable.php b/src/data/src/Casts/Uncastable.php index 1803e27f3f..3c47c468a5 100644 --- a/src/data/src/Casts/Uncastable.php +++ b/src/data/src/Casts/Uncastable.php @@ -4,9 +4,9 @@ namespace Hypervel\Data\Casts; -enum Uncastable +enum Uncastable: string { - case Instance; + case Instance = 'Instance'; /** * Get the uncastable sentinel. diff --git a/src/data/src/Concerns/BaseData.php b/src/data/src/Concerns/BaseData.php index 1922fa4fa9..ebc8ecc01c 100644 --- a/src/data/src/Concerns/BaseData.php +++ b/src/data/src/Concerns/BaseData.php @@ -12,6 +12,7 @@ use Hypervel\Data\CursorPaginatedDataCollection; use Hypervel\Data\DataCollection; use Hypervel\Data\PaginatedDataCollection; +use Hypervel\Data\Support\Creation\CreationContext; use Hypervel\Data\Support\Creation\CreationContextFactory; use Hypervel\Data\Support\Creation\DataCreator; use Hypervel\Database\Eloquent\Collection as EloquentCollection; @@ -67,7 +68,7 @@ public static function from(mixed ...$payloads): static * @template TCollectValue of BaseDataContract * @template TModelValue of Model * - * @param AbstractCursorPaginator|AbstractPaginator|array|Collection|CursorPaginatedDataCollection|CursorPaginatorContract|DataCollection|EloquentCollection|Enumerable|LazyCollection|LengthAwarePaginatorContract|PaginatedDataCollection|PaginatorContract|Traversable $items + * @param null|AbstractCursorPaginator|AbstractPaginator|array|Collection|CursorPaginatedDataCollection|CursorPaginatorContract|DataCollection|EloquentCollection|Enumerable|LazyCollection|LengthAwarePaginatorContract|PaginatedDataCollection|PaginatorContract|Traversable $items null collects an empty `$into` target * @param null|'array'|class-string $into * @return ( * $into is null @@ -135,19 +136,17 @@ public static function collect(mixed $items, ?string $into = null): array|DataCo } // REMOVED: Deprecated collection() and Enumerable forwarding; use collect() and toCollection(). - // REMOVED: Factories cannot inherit mutable in-flight creation contexts; every call starts fresh. - /** - * Create a fresh data construction factory. + * Create a fresh data construction factory, optionally with another creation's options. * * @return CreationContextFactory */ - public static function factory(): CreationContextFactory + public static function factory(?CreationContext $creationContext = null): CreationContextFactory { /** @var CreationContextFactory $factory */ $factory = Container::getInstance() ->make(DataCreator::class) - ->factory(static::class); + ->factory(static::class, $creationContext); return $factory; } @@ -160,7 +159,18 @@ public static function normalizers(): array return []; } - // REMOVED: Configurable pipelines and prepareForPipeline(); use named factories and factory hooks. + /** + * Prepare one normalized payload before its properties are read. + * + * @param array $properties + * @return array + */ + public static function prepareForPipeline(array $properties): array + { + return $properties; + } + + // REMOVED: Configurable pipelines; use named factories, prepareForPipeline(), and factory hooks. /** * Create a data object from the current request. diff --git a/src/data/src/Concerns/BaseDataCollectable.php b/src/data/src/Concerns/BaseDataCollectable.php index 3c1034d18b..9eaebb63e7 100644 --- a/src/data/src/Concerns/BaseDataCollectable.php +++ b/src/data/src/Concerns/BaseDataCollectable.php @@ -6,6 +6,8 @@ use Generator; use Hypervel\Data\Contracts\BaseData; +use Hypervel\Data\Contracts\IncludeableData; +use Hypervel\Data\Support\Partials\PartialDefinition; use Hypervel\Data\Support\Partials\PartialsDefinition; /** @@ -31,11 +33,41 @@ public function getDataClass(): string */ public function getIterator(): Generator { + $partials = $this->partialsForItems(); + foreach ($this->itemsForIteration() as $key => $item) { + if ($partials !== null && $item instanceof IncludeableData) { + $item->getPartialsDefinition()->addResolved($partials); + } + yield $key => $item; } } + /** + * Resolve the collection's active partials for items read from it. + * + * As in Spatie, an item read from the collection carries the collection's selections. + * Reads don't consume the collection's temporary selections; its next transformation does. + * + * @return null|array{include: list, exclude: list, only: list, except: list} + */ + protected function partialsForItems(): ?array + { + if (! $this->hasPartialsDefinition()) { + return null; + } + + $partials = $this->getPartialsDefinition()->resolve($this); + + return PartialsDefinition::hasResolved($partials) ? $partials : null; + } + + /** + * Determine whether this object has partial definitions. + */ + abstract public function hasPartialsDefinition(): bool; + /** * Get the current partial definitions. */ diff --git a/src/data/src/Concerns/ResponsableData.php b/src/data/src/Concerns/ResponsableData.php index a2fb05180f..0a08f5af58 100644 --- a/src/data/src/Concerns/ResponsableData.php +++ b/src/data/src/Concerns/ResponsableData.php @@ -29,9 +29,10 @@ public function toResponse(Request $request): JsonResponse { $data = $this; $container = Container::getInstance(); + // Nested data collections wrap as in Spatie; the resource wraps the root itself. $contextFactory = $container ->make(RequestQueryStringPartialsResolver::class) - ->resolve($data, $request, TransformationContextFactory::create()); + ->resolve($data, $request, TransformationContextFactory::create()->withWrapping()); $context = $contextFactory->get($data); $transformer = $container->make(DataTransformer::class); $wrapper = $data->getWrap()->getKey( @@ -51,6 +52,7 @@ public function toResponse(Request $request): JsonResponse $originalItems, $transformed, $wrapper, + $transformer, ))->toResponse($request); } diff --git a/src/data/src/Concerns/WrappableData.php b/src/data/src/Concerns/WrappableData.php index f5df478ef8..fe9ff35118 100644 --- a/src/data/src/Concerns/WrappableData.php +++ b/src/data/src/Concerns/WrappableData.php @@ -36,6 +36,8 @@ public function wrap(string $key): static */ public function getWrap(): Wrap { - return $this->wrap ?? new Wrap(WrapType::UseGlobal); + return $this->wrap ?? (method_exists($this, 'defaultWrap') + ? new Wrap(WrapType::Defined, $this->defaultWrap()) + : new Wrap(WrapType::UseGlobal)); } } diff --git a/src/data/src/Console/DataMakeCommand.php b/src/data/src/Console/DataMakeCommand.php index 038d03f5f3..a842b62c89 100644 --- a/src/data/src/Console/DataMakeCommand.php +++ b/src/data/src/Console/DataMakeCommand.php @@ -5,6 +5,7 @@ namespace Hypervel\Data\Console; use Hypervel\Console\GeneratorCommand; +use Hypervel\Support\Str; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputOption; @@ -49,7 +50,24 @@ protected function resolveStubPath(string $stub): string */ protected function getDefaultNamespace(string $rootNamespace): string { - return $rootNamespace . '\Data'; + // Hypervel's generators already provide --target-namespace for a one-off namespace. + $namespace = trim(config()->string('data.commands.make.namespace'), '\\'); + + return trim($rootNamespace . '\\' . $namespace, '\\'); + } + + /** + * Parse the class name and format according to the root namespace. + */ + protected function qualifyClass(string $name): string + { + $suffix = trim($this->option('suffix')); + + if ($suffix !== '' && ! Str::endsWith($name, $suffix)) { + $name .= $suffix; + } + + return parent::qualifyClass($name); } /** @@ -58,7 +76,19 @@ protected function getDefaultNamespace(string $rootNamespace): string protected function getOptions(): array { return [ - ['force', 'f', InputOption::VALUE_NONE, 'Create the Data class even if it already exists'], + [ + 'suffix', + 's', + InputOption::VALUE_REQUIRED, + 'Suffix the class with this value.', + config()->string('data.commands.make.suffix'), + ], + [ + 'force', + 'f', + InputOption::VALUE_NONE, + 'Create the Data class even if the file already exists.', + ], ]; } } diff --git a/src/data/src/Contracts/BaseData.php b/src/data/src/Contracts/BaseData.php index ef3343d18f..1f579c8a16 100644 --- a/src/data/src/Contracts/BaseData.php +++ b/src/data/src/Contracts/BaseData.php @@ -13,6 +13,7 @@ use Hypervel\Data\DataCollection; use Hypervel\Data\Normalizers\Normalizer; use Hypervel\Data\PaginatedDataCollection; +use Hypervel\Data\Support\Creation\CreationContext; use Hypervel\Data\Support\Creation\CreationContextFactory; use Hypervel\Database\Eloquent\Collection as EloquentCollection; use Hypervel\Database\Eloquent\Model; @@ -48,7 +49,7 @@ public static function from(mixed ...$payloads): static; * @template TCollectValue of BaseData * @template TModelValue of Model * - * @param AbstractCursorPaginator|AbstractPaginator|array|Collection|CursorPaginatedDataCollection|CursorPaginatorContract|DataCollection|EloquentCollection|Enumerable|LazyCollection|LengthAwarePaginatorContract|PaginatedDataCollection|PaginatorContract|Traversable $items + * @param null|AbstractCursorPaginator|AbstractPaginator|array|Collection|CursorPaginatedDataCollection|CursorPaginatorContract|DataCollection|EloquentCollection|Enumerable|LazyCollection|LengthAwarePaginatorContract|PaginatedDataCollection|PaginatorContract|Traversable $items null collects an empty `$into` target * @param null|'array'|class-string $into * @return ( * $into is null @@ -113,11 +114,11 @@ public static function from(mixed ...$payloads): static; public static function collect(mixed $items, ?string $into = null): array|DataCollection|PaginatedDataCollection|CursorPaginatedDataCollection|Enumerable|AbstractPaginator|PaginatorContract|AbstractCursorPaginator|CursorPaginatorContract|LazyCollection|Collection; /** - * Create a data construction factory. + * Create a data construction factory, optionally with another creation's options. * * @return CreationContextFactory */ - public static function factory(): CreationContextFactory; + public static function factory(?CreationContext $creationContext = null): CreationContextFactory; /** * Get the data normalizers. @@ -125,4 +126,12 @@ public static function factory(): CreationContextFactory; * @return list> */ public static function normalizers(): array; + + /** + * Prepare one normalized payload before its properties are read. + * + * @param array $properties + * @return array + */ + public static function prepareForPipeline(array $properties): array; } diff --git a/src/data/src/Data.php b/src/data/src/Data.php index edcf2e1c6a..c9c64ef3c4 100644 --- a/src/data/src/Data.php +++ b/src/data/src/Data.php @@ -34,4 +34,6 @@ abstract class Data implements AppendableDataContract, BaseDataContract, Eloquen use TransformableDataConcern; use ValidateableDataConcern; use WrappableDataConcern; + + // REMOVED: ContextableData; partial selections and wrapping live on IncludeableData and WrappableData. } diff --git a/src/data/src/DataCollection.php b/src/data/src/DataCollection.php index 4186336e2b..22ba86c7fa 100644 --- a/src/data/src/DataCollection.php +++ b/src/data/src/DataCollection.php @@ -120,7 +120,13 @@ public function offsetGet(mixed $offset): mixed throw InvalidDataCollectionOperation::create(); } - return $this->items->offsetGet($offset); + $item = $this->items->offsetGet($offset); + + if ($item instanceof IncludeableDataContract && ($partials = $this->partialsForItems()) !== null) { + $item->getPartialsDefinition()->addResolved($partials); + } + + return $item; } /** diff --git a/src/data/src/DataServiceProvider.php b/src/data/src/DataServiceProvider.php index b129645955..73e8ca3e9b 100644 --- a/src/data/src/DataServiceProvider.php +++ b/src/data/src/DataServiceProvider.php @@ -4,6 +4,7 @@ namespace Hypervel\Data; +use Hypervel\Contracts\Config\Repository; use Hypervel\Contracts\Foundation\Application; use Hypervel\Data\Console\DataMakeCommand; use Hypervel\Data\Contracts\TransformableData; @@ -12,6 +13,7 @@ use Hypervel\Data\Support\Transformation\DataTransformer; use Hypervel\Data\Support\VarDumper\DataVarDumperCaster; use Hypervel\Support\ServiceProvider; +use InvalidArgumentException; use Symfony\Component\VarDumper\Cloner\AbstractCloner; class DataServiceProvider extends ServiceProvider @@ -33,7 +35,7 @@ public function register(): void /** * Bootstrap data services. */ - public function boot(): void + public function boot(Repository $config): void { // Build the typed configuration once during worker boot. $this->app->make(DataConfig::class); @@ -45,8 +47,19 @@ public function boot(): void }); } - AbstractCloner::$defaultCasters[TransformableData::class] - ??= [DataVarDumperCaster::class, 'cast']; + $enableVarDumperCaster = match ($config->string('data.var_dumper_caster_mode')) { + 'enabled' => true, + 'development' => $this->app->environment('local', 'testing'), + 'disabled' => false, + default => throw new InvalidArgumentException( + "Configuration [data.var_dumper_caster_mode] must be 'enabled', 'disabled' or 'development'.", + ), + }; + + if ($enableVarDumperCaster) { + AbstractCloner::$defaultCasters[TransformableData::class] + ??= [DataVarDumperCaster::class, 'cast']; + } if ($this->app->runningInConsole()) { // REMOVED: data:cache-structures; worker memory is the metadata cache boundary. diff --git a/src/data/src/Eloquent/AbstractDataEloquentCast.php b/src/data/src/Eloquent/AbstractDataEloquentCast.php index f3d79fce19..cbe3b11260 100644 --- a/src/data/src/Eloquent/AbstractDataEloquentCast.php +++ b/src/data/src/Eloquent/AbstractDataEloquentCast.php @@ -78,32 +78,29 @@ protected function isAbstractClassCast(): bool } /** - * Resolve data from a strict abstract-class envelope. + * Resolve data from an abstract-class envelope. * * @param array $payload * @return TData */ protected function resolveMorphedData(Model $model, string $key, array $payload): BaseData { - $alias = $payload['type'] ?? null; + $type = $payload['type'] ?? null; $data = $payload['data'] ?? null; - if (! is_string($alias) || ! is_array($data)) { + if (! is_string($type) || ! is_array($data)) { throw CannotCastData::invalidMorphEnvelope($model::class, $key); } - $dataClass = $this->dataConfig->getMorphedDataClass($alias); + // The stored type is an alias or a class name, which must stay within the declared type before its + // metadata is built. + $dataClass = $this->dataConfig->getMorphedDataClass($type) ?? $type; - if ($dataClass === null) { - throw CannotCastData::unknownMorphAlias($alias, $this->dataClass); + if (! is_a($dataClass, $this->dataClass, true)) { + throw CannotCastData::invalidMorphClass($dataClass, $this->dataClass); } - $metadata = $this->dataClasses->get($dataClass); - - if (! is_a($dataClass, $this->dataClass, true) - || $metadata->isAbstract - || ! $metadata->transformable - ) { + if ($this->dataClasses->get($dataClass)->isAbstract) { throw CannotCastData::invalidMorphClass($dataClass, $this->dataClass); } @@ -112,7 +109,7 @@ protected function resolveMorphedData(Model $model, string $key, array $payload) } /** - * Wrap transformed data in its enforced abstract-class envelope. + * Wrap transformed data in its abstract-class envelope, under its alias or class name. * * @param TData $data * @param array $payload @@ -120,14 +117,8 @@ protected function resolveMorphedData(Model $model, string $key, array $payload) */ protected function createMorphEnvelope(BaseData&TransformableData $data, array $payload): array { - $alias = $this->dataConfig->getDataClassAlias($data::class); - - if ($alias === null) { - throw CannotCastData::morphAliasRequired($data::class); - } - return [ - 'type' => $alias, + 'type' => $this->dataConfig->getDataClassAlias($data::class) ?? $data::class, 'data' => $payload, ]; } diff --git a/src/data/src/Eloquent/DataCollectionEloquentCast.php b/src/data/src/Eloquent/DataCollectionEloquentCast.php index 7da0d1a8e4..2bc3f34ea8 100644 --- a/src/data/src/Eloquent/DataCollectionEloquentCast.php +++ b/src/data/src/Eloquent/DataCollectionEloquentCast.php @@ -5,6 +5,7 @@ namespace Hypervel\Data\Eloquent; use Hypervel\Contracts\Database\Eloquent\CastsAttributes; +use Hypervel\Contracts\Support\Arrayable; use Hypervel\Data\Contracts\BaseData; use Hypervel\Data\Contracts\TransformableData; use Hypervel\Data\DataCollection; @@ -12,6 +13,7 @@ use Hypervel\Database\Eloquent\Casts\Json; use Hypervel\Database\Eloquent\JsonEncodingException; use Hypervel\Database\Eloquent\Model; +use Hypervel\Support\Enumerable; use Hypervel\Support\Facades\Crypt; /** @@ -19,7 +21,7 @@ * @template TDataCollection of DataCollection * * @extends AbstractDataEloquentCast - * @implements CastsAttributes|TData>|TDataCollection> + * @implements CastsAttributes|TData>|Arrayable|TData>>|TDataCollection> */ class DataCollectionEloquentCast extends AbstractDataEloquentCast implements CastsAttributes { @@ -84,7 +86,7 @@ public function get(Model $model, string $key, mixed $value, array $attributes): /** * Transform a data collection into its stored representation. * - * @param null|array|TData>|TDataCollection $value + * @param null|array|TData>|Arrayable|TData>>|TDataCollection $value * @param array $attributes */ public function set(Model $model, string $key, mixed $value, array $attributes): ?string @@ -93,8 +95,14 @@ public function set(Model $model, string $key, mixed $value, array $attributes): return null; } + // Collections keep their data objects, since toArray() would give their output form, not the values needed to + // recreate them. if ($value instanceof DataCollection) { $value = $value->items(); + } elseif ($value instanceof Enumerable) { + $value = $value->all(); + } elseif ($value instanceof Arrayable) { + $value = $value->toArray(); } if (! is_array($value)) { diff --git a/src/data/src/Enums/DataPropertyOperation.php b/src/data/src/Enums/DataPropertyOperation.php index 2429c0cd42..ed4d53aed6 100644 --- a/src/data/src/Enums/DataPropertyOperation.php +++ b/src/data/src/Enums/DataPropertyOperation.php @@ -4,11 +4,11 @@ namespace Hypervel\Data\Enums; -enum DataPropertyOperation +enum DataPropertyOperation: string { - case Copy; - case Builtin; - case Enum; - case Date; - case Data; + case Copy = 'Copy'; + case Builtin = 'Builtin'; + case Enum = 'Enum'; + case Date = 'Date'; + case Data = 'Data'; } diff --git a/src/data/src/Exceptions/CannotCastData.php b/src/data/src/Exceptions/CannotCastData.php index 4bc5688876..29c3576dfc 100644 --- a/src/data/src/Exceptions/CannotCastData.php +++ b/src/data/src/Exceptions/CannotCastData.php @@ -89,31 +89,15 @@ public static function invalidMorphEnvelope(string $modelClass, string $attribut } /** - * Create an exception for an unknown data morph alias. + * Create an exception for a stored data type that names no valid class. */ - public static function unknownMorphAlias(string $alias, string $dataClass): self - { - return new self("Data morph alias `{$alias}` is not registered for `{$dataClass}`"); - } - - /** - * Create an exception for an invalid data morph class. - */ - public static function invalidMorphClass(string $class, string $dataClass): self + public static function invalidMorphClass(string $type, string $dataClass): self { return new self( - "Data morph class `{$class}` should be a concrete transformable subtype of `{$dataClass}`", + "Data morph type `{$type}` should be a registered alias or a concrete transformable subtype of `{$dataClass}`", ); } - /** - * Create an exception for a data class without an enforced morph alias. - */ - public static function morphAliasRequired(string $class): self - { - return new self("Data class `{$class}` should have an enforced morph alias"); - } - /** * Create an exception for a missing collection item type. */ diff --git a/src/data/src/Exceptions/CannotCreateData.php b/src/data/src/Exceptions/CannotCreateData.php index 507633398f..a677b4d6c1 100644 --- a/src/data/src/Exceptions/CannotCreateData.php +++ b/src/data/src/Exceptions/CannotCreateData.php @@ -6,6 +6,7 @@ use Exception; use Hypervel\Data\Support\DataClass; +use Hypervel\Data\Support\DataMethod; use Hypervel\Data\Support\DataProperty; class CannotCreateData extends Exception @@ -127,18 +128,29 @@ public static function ambiguousDataObjectUnion( } /** - * Create an exception for an ambiguous data-collectable union. + * Create an exception for a value no single container type of a union accepts. * * @param list $candidates */ - public static function ambiguousDataCollectableUnion( + public static function ambiguousContainerUnion( DataProperty $property, array $candidates, ): self { return new self( "Could not create property [{$property->className}::\${$property->name}] from an ambiguous " - . 'data-collectable union [' . implode(', ', $candidates) . ']. Supply a finished compatible ' - . 'container, define an explicit cast, or return the target object from a named factory.' + . 'container union [' . implode(', ', $candidates) . ']. Supply a value exactly one container ' + . 'type accepts, define an explicit cast, or return the target object from a named factory.' + ); + } + + /** + * Create an exception for a named factory that did not return the requested object. + */ + public static function invalidNamedFactoryResult(DataClass $dataClass, DataMethod $method, mixed $value): self + { + return new self( + "Could not create data class [{$dataClass->name}]: the named factory [{$method->name}()] returned [" + . get_debug_type($value) . "] instead of an instance of [{$dataClass->name}]." ); } diff --git a/src/data/src/Exceptions/CannotFindDataClass.php b/src/data/src/Exceptions/CannotFindDataClass.php index 05b9df18eb..2120d626ae 100644 --- a/src/data/src/Exceptions/CannotFindDataClass.php +++ b/src/data/src/Exceptions/CannotFindDataClass.php @@ -6,9 +6,6 @@ use Exception; use Hypervel\Data\Contracts\BaseData; -use ReflectionMethod; -use ReflectionParameter; -use ReflectionProperty; class CannotFindDataClass extends Exception { @@ -33,24 +30,4 @@ public static function forSource(string $source, mixed $dataClass): self return new self("Class [{$source}] declared data class [{$declared}], which must implement [" . BaseData::class . '].'); } - - /** - * Create an exception for a declaration without a data class. - */ - public static function forTypeable(ReflectionMethod|ReflectionProperty|ReflectionParameter|string $typeable): self - { - if (is_string($typeable)) { - return new self("Cannot find a data class for type [{$typeable}]."); - } - - $class = $typeable->getDeclaringClass()?->getName() ?? 'unknown'; - - $name = match (true) { - $typeable instanceof ReflectionMethod => "method [{$class}::{$typeable->getName()}]", - $typeable instanceof ReflectionProperty => "property [{$class}::\${$typeable->getName()}]", - $typeable instanceof ReflectionParameter => "parameter [{$class}::{$typeable->getDeclaringFunction()->getName()}(\${$typeable->getName()})]", - }; - - return new self("Cannot find a data class for {$name}."); - } } diff --git a/src/data/src/Exceptions/CannotPerformPartialOnDataField.php b/src/data/src/Exceptions/CannotPerformPartialOnDataField.php index e78d5b21d3..204f1dedfe 100644 --- a/src/data/src/Exceptions/CannotPerformPartialOnDataField.php +++ b/src/data/src/Exceptions/CannotPerformPartialOnDataField.php @@ -27,7 +27,7 @@ public static function missingProperty( string $class, ): self { return new self(sprintf( - 'Cannot %s unknown data property [%s] on [%s].', + 'Cannot apply the [%s] partial to unknown data property [%s] on [%s].', $operation, $field, $class, diff --git a/src/data/src/Exceptions/CannotResolveRouteParameterReference.php b/src/data/src/Exceptions/CannotResolveRouteParameterReference.php new file mode 100644 index 0000000000..d021d4a2a6 --- /dev/null +++ b/src/data/src/Exceptions/CannotResolveRouteParameterReference.php @@ -0,0 +1,28 @@ +reflection->getDeclaringClass()?->getName() ?? $class; - - return new self( - "Data class [{$class}] promotes non-public property [{$declaringClass}::\${$parameter->name}]. " - . 'Promoted data properties must be public.' - ); - } - - /** - * Create an exception for a constructor parameter without a data property. - * - * @param class-string $class - */ - public static function missingDataProperty(string $class, DataParameter $parameter): self - { - $declaringClass = $parameter->reflection->getDeclaringClass()?->getName() ?? $class; - - return new self( - "Data class [{$class}] constructor parameter [{$declaringClass}::\${$parameter->name}] has no " - . 'corresponding public data property or contextual attribute. Promote the parameter, declare a ' - . 'public property with the same name, or use a named factory.' - ); - } - /** * Create an exception for a non-promoted readonly input property. * diff --git a/src/data/src/Http/RequestQueryStringPartialsResolver.php b/src/data/src/Http/RequestQueryStringPartialsResolver.php index 49feefa52d..1c776bfbea 100644 --- a/src/data/src/Http/RequestQueryStringPartialsResolver.php +++ b/src/data/src/Http/RequestQueryStringPartialsResolver.php @@ -184,7 +184,7 @@ protected function validateTree( * Find a data property by its PHP or mapped output name. */ protected function findProperty( - string $field, + string|int $field, DataClass $dataClass, ): ?DataProperty { if (isset($dataClass->properties[$field])) { diff --git a/src/data/src/Http/Resources/DataCollectionResource.php b/src/data/src/Http/Resources/DataCollectionResource.php index 73cd389ec0..e6e7e64faa 100644 --- a/src/data/src/Http/Resources/DataCollectionResource.php +++ b/src/data/src/Http/Resources/DataCollectionResource.php @@ -8,6 +8,7 @@ use Hypervel\Data\Contracts\ResponsableData; use Hypervel\Data\CursorPaginatedDataCollection; use Hypervel\Data\PaginatedDataCollection; +use Hypervel\Data\Support\Transformation\DataTransformer; use Hypervel\Http\JsonResponse; use Hypervel\Http\Request; use Hypervel\Http\Resources\Json\ProvidesResourceWrapper; @@ -25,19 +26,14 @@ class DataCollectionResource extends ResourceCollection implements ProvidesResou * @param array $transformed */ public function __construct( - protected readonly BaseDataCollectable $data, + protected readonly BaseDataCollectable&ResponsableData $data, protected readonly Collection $originalItems, protected readonly array $transformed, protected readonly ?string $wrapper, + protected readonly DataTransformer $transformer, ) { - $resource = match (true) { - $data instanceof PaginatedDataCollection, - $data instanceof CursorPaginatedDataCollection => (clone $data->items()) - ->setCollection(new Collection($transformed)), - default => $transformed, - }; - - parent::__construct($resource); + // A paginator holding the transformed rows would derive its cursors from them, so pagination is added by with(). + parent::__construct($transformed); } /** @@ -48,6 +44,18 @@ public function resolve(?Request $request = null): array return $this->transformed; } + /** + * Get the pagination links and metadata of a paginated collection, matching its transformed output. + */ + public function with(Request $request): array + { + $data = $this->data; + + return $data instanceof PaginatedDataCollection || $data instanceof CursorPaginatedDataCollection + ? $this->transformer->paginationInformation($data->items(), $this->transformed) + : []; + } + /** * Get the per-instance response wrapper. */ @@ -75,6 +83,8 @@ public function withResponse(Request $request, JsonResponse $response): void { // Collections have no item-owned response hook, so retain the original Data objects. $response->original = $this->originalItems; + + $this->data->withResponse($request, $response); } /** diff --git a/src/data/src/Normalizers/FormRequestNormalizer.php b/src/data/src/Normalizers/FormRequestNormalizer.php new file mode 100644 index 0000000000..c18b32e572 --- /dev/null +++ b/src/data/src/Normalizers/FormRequestNormalizer.php @@ -0,0 +1,22 @@ +validated(); + } +} diff --git a/src/data/src/Normalizers/Normalized/NormalizedModel.php b/src/data/src/Normalizers/Normalized/NormalizedModel.php index 2bf9a7082d..7fb43cb29f 100644 --- a/src/data/src/Normalizers/Normalized/NormalizedModel.php +++ b/src/data/src/Normalizers/Normalized/NormalizedModel.php @@ -5,6 +5,7 @@ namespace Hypervel\Data\Normalizers\Normalized; use Hypervel\Data\Support\DataProperty; +use Hypervel\Database\Eloquent\MissingAttributeException; use Hypervel\Database\Eloquent\Model; use Hypervel\Support\StrCache; @@ -54,10 +55,28 @@ protected function fetchNewProperty(string $name, DataProperty $dataProperty): m return $this->properties[$name] = $this->model->getRelation($camelName); } - if ($this->model->hasAttribute($name)) { - return $this->properties[$name] = $this->model->getAttribute($name); + // Names that are not attributes are still read, because an overridden getAttribute() may supply + // them. Unloaded relations are skipped so this read never lazy-loads one; only LoadRelation above does. + if (! $this->model->hasAttribute($name) + && ($this->model->isRelation($name) || $this->model->isRelation($camelName)) + ) { + return $this->properties[$name] = UnknownProperty::create(); + } + + try { + $value = $this->model->getAttribute($name); + } catch (MissingAttributeException) { + return $this->properties[$name] = UnknownProperty::create(); + } + + // Without a getter, unselected columns and unknown names also read as null, but they are absent rather than supplied. + if ($value === null + && ! array_key_exists($name, $this->model->getAttributes()) + && ! $this->model->hasAnyGetMutator($name) + ) { + return $this->properties[$name] = UnknownProperty::create(); } - return $this->properties[$name] = UnknownProperty::create(); + return $this->properties[$name] = $value; } } diff --git a/src/data/src/Normalizers/Normalized/UnknownProperty.php b/src/data/src/Normalizers/Normalized/UnknownProperty.php index da95a30d44..f4135ad236 100644 --- a/src/data/src/Normalizers/Normalized/UnknownProperty.php +++ b/src/data/src/Normalizers/Normalized/UnknownProperty.php @@ -4,9 +4,9 @@ namespace Hypervel\Data\Normalizers\Normalized; -enum UnknownProperty +enum UnknownProperty: string { - case Instance; + case Instance = 'Instance'; /** * Get the missing-property sentinel. diff --git a/src/data/src/RuleInferrers/RuleInferrer.php b/src/data/src/RuleInferrers/RuleInferrer.php new file mode 100644 index 0000000000..436676995d --- /dev/null +++ b/src/data/src/RuleInferrers/RuleInferrer.php @@ -0,0 +1,18 @@ +`. + * + * @param ReflectionClass $class + */ + public function getForCollectionClass(ReflectionClass $class): ?DataIterableAnnotation + { + // Only an iterable class describes its items; an interface's @extends tags name parent interfaces. + $node = $class->isInterface() || ! $class->implementsInterface(Traversable::class) + ? null + : $this->parse($class->getDocComment()); + $extends = $node?->getExtendsTagValues()[0] ?? null; + + if ($node === null || $extends === null || $extends->type->genericTypes === []) { + return null; + } + + $genericTypes = $extends->type->genericTypes; + $itemType = $genericTypes[1] ?? $genericTypes[0]; + + // A template item, such as `TData of SongData`, is read as its bound; an unbounded one has no item type. + if ($itemType instanceof IdentifierTypeNode) { + foreach ($node->getTags() as $tag) { + if ($tag->value instanceof TemplateTagValueNode && $tag->value->name === $itemType->name) { + $itemType = $tag->value->bound; + + break; + } + } + } + + return $itemType === null ? null : new DataIterableAnnotation( + containerType: $extends->type->type->name, + itemType: $itemType, + declaringClass: $class->getName(), + ); + } + /** * Get iterable annotations declared for a property. * diff --git a/src/data/src/Support/Creation/AutoLazyReplayMode.php b/src/data/src/Support/Creation/AutoLazyReplayMode.php index f2dfe7e726..6f3f4f1488 100644 --- a/src/data/src/Support/Creation/AutoLazyReplayMode.php +++ b/src/data/src/Support/Creation/AutoLazyReplayMode.php @@ -4,8 +4,8 @@ namespace Hypervel\Data\Support\Creation; -enum AutoLazyReplayMode +enum AutoLazyReplayMode: string { - case Normal; - case Hook; + case Normal = 'Normal'; + case Hook = 'Hook'; } diff --git a/src/data/src/Support/Creation/ConstructionState.php b/src/data/src/Support/Creation/ConstructionState.php index 92ed5ae78c..3b34bdc81d 100644 --- a/src/data/src/Support/Creation/ConstructionState.php +++ b/src/data/src/Support/Creation/ConstructionState.php @@ -6,6 +6,11 @@ use Hypervel\Data\Contracts\BaseData; use Hypervel\Data\Normalizers\Normalized\UnknownProperty; +use Hypervel\Data\RuleInferrers\RuleInferrer; +use Hypervel\Data\Support\DataClass; +use Hypervel\Data\Support\DataMethod; +use Hypervel\Data\Support\DataMethodMatch; +use Hypervel\Data\Support\Types\NamedType; use Hypervel\Pagination\AbstractCursorPaginator; use Hypervel\Pagination\AbstractPaginator; @@ -17,13 +22,33 @@ final class ConstructionState /** @var null|array */ private ?array $unknownInput = null; + /** + * The rule inferrers resolved for the current compilation. + * + * @var list + */ + private array $ruleInferrers = []; + + /** + * Whether named factories and constructors wait for the cast that owns the current value. + */ + private bool $executionDeferred = false; + + /** + * Whether a deferred named factory was recorded anywhere in the structure. + */ + private bool $hasNamedFactories = false; + /** * @var array{ * class: null|class-string, * mappings: array, * children: array, + * selectedTypes?: array, * autoLazy?: array, * paginatorSource?: AbstractCursorPaginator|AbstractPaginator, + * namedFactory?: array{DataClass, DataMethod, DataMethodMatch}, + * contextualPrepared?: true, * uniform?: false, * items?: array * } @@ -113,6 +138,32 @@ public function path(): array return $path; } + /** + * Write the normalized input the current node's declared values are filled over. + * + * @param array $input + */ + public function writeNodeInput(array $input): void + { + $path = $this->path(); + + if ($path === []) { + $this->payload = $input; + + return; + } + + $this->writeAtPath($path, $input, false); + } + + /** + * Restore the given value at the current nested path when it cannot be read as node input. + */ + public function writeNodeValue(mixed $value): void + { + $this->writeAtPath($this->path(), $value, false); + } + /** * Write a mapped property value beneath the current path. * @@ -141,6 +192,28 @@ public function writeFinishedPropertyValue(array $path, mixed $value): void ); } + /** + * Remove a mapped property value beneath the current path. + * + * @param non-empty-list $path + */ + public function forgetPropertyValue(array $path): void + { + $path = [...$this->path(), ...$path]; + $lastKey = array_pop($path); + $slot = &$this->payload; + + foreach ($path as $pathKey) { + if (! array_key_exists($pathKey, $slot) || ! is_array($slot[$pathKey])) { + return; + } + + $slot = &$slot[$pathKey]; + } + + unset($slot[$lastKey]); + } + /** * Write a raw collection item beneath the current path. */ @@ -181,6 +254,16 @@ public function getValue(array $path): mixed return $value instanceof UnknownProperty ? null : $value; } + /** + * Read a value beneath the current path, or an unknown property when it is absent. + * + * @param non-empty-list $path + */ + public function read(array $path): mixed + { + return $this->valueAtPath($path); + } + /** * Get the complete construction payload. * @@ -248,6 +331,26 @@ public function unknownInput(): ?array return $this->unknownInput; } + /** + * Set the rule inferrers resolved for the current compilation. + * + * @param list $ruleInferrers + */ + public function setRuleInferrers(array $ruleInferrers): void + { + $this->ruleInferrers = $ruleInferrers; + } + + /** + * Get the rule inferrers resolved for the current compilation. + * + * @return list + */ + public function ruleInferrers(): array + { + return $this->ruleInferrers; + } + /** * Record the chosen wire key for a property on the current node. */ @@ -329,7 +432,7 @@ public function resetNodeStructure(): void $node['class'] = null; $node['mappings'] = []; $node['children'] = []; - unset($node['autoLazy'], $node['paginatorSource']); + unset($node['selectedTypes'], $node['autoLazy'], $node['paginatorSource'], $node['namedFactory'], $node['contextualPrepared']); if ($this->pathContainsItem()) { $this->markEnclosingCollectionsNonUniform(); @@ -418,6 +521,147 @@ public function nodeClass(): ?string return $this->structureNodeAtCurrentPath()['class'] ?? null; } + /** + * Determine if named factories and constructors wait for the cast that owns the current value. + */ + public function executionDeferred(): bool + { + return $this->executionDeferred; + } + + /** + * Start or stop deferring execution beneath a cast-owned value. + */ + public function setExecutionDeferred(bool $deferred): void + { + $this->executionDeferred = $deferred; + } + + /** + * Record the named factory that creates the current node once its owning cast declines. + * + * Each node keeps its own match, because the match holds that node's input. + */ + public function recordNamedFactory(DataClass $dataClass, DataMethod $method, DataMethodMatch $match): void + { + if ($this->pathContainsItem()) { + $node = &$this->ensureOverrideNodeAtCurrentPath(); + } else { + $node = &$this->ensureStructureNodeAtCurrentPath(); + } + + $node['namedFactory'] = [$dataClass, $method, $match]; + $this->hasNamedFactories = true; + } + + /** + * Forget the named factory recorded for the current node, whose input a validation hook replaced. + */ + public function forgetNamedFactory(): void + { + if (! $this->hasNamedFactories) { + return; + } + + if ($this->pathContainsItem()) { + $node = &$this->ensureOverrideNodeAtCurrentPath(); + } else { + $node = &$this->ensureStructureNodeAtCurrentPath(); + } + + unset($node['namedFactory']); + } + + /** + * Get the named factory recorded for the current node. + * + * @return null|array{DataClass, DataMethod, DataMethodMatch} + */ + public function namedFactory(): ?array + { + if (! $this->hasNamedFactories) { + return null; + } + + $node = $this->pathContainsItem() + ? $this->overrideNodeAtCurrentPath() + : $this->structureNodeAtCurrentPath(); + + return $node['namedFactory'] ?? null; + } + + /** + * Record that the current node's contextual property values were resolved and filled for validation. + */ + public function markContextualValuesPrepared(): void + { + if ($this->pathContainsItem()) { + $node = &$this->ensureOverrideNodeAtCurrentPath(); + } else { + $node = &$this->ensureStructureNodeAtCurrentPath(); + } + + $node['contextualPrepared'] = true; + } + + /** + * Determine if the current node's contextual property values were resolved and filled for validation. + */ + public function contextualValuesPrepared(): bool + { + $node = $this->pathContainsItem() + ? $this->overrideNodeAtCurrentPath() + : $this->structureNodeAtCurrentPath(); + + return $node['contextualPrepared'] ?? false; + } + + /** + * Record the declared type a property's value selected on the current node. + * + * Null records that the value is kept by a type that needs no conversion, or that no single type selects it. + */ + public function recordSelectedType(string $property, ?NamedType $type): void + { + $template = &$this->ensureStructureNodeAtCurrentPath(); + + if (! $this->pathContainsItem() || ! array_key_exists($property, $template['selectedTypes'] ?? [])) { + $template['selectedTypes'][$property] = $type; + + return; + } + + $matchesTemplate = $template['selectedTypes'][$property] === $type; + + if ($matchesTemplate + && ! array_key_exists($property, $this->overrideNodeAtCurrentPath()['selectedTypes'] ?? []) + ) { + return; + } + + $override = &$this->ensureOverrideNodeAtCurrentPath(); + unset($override['selectedTypes'][$property]); + + if (! $matchesTemplate) { + $override['selectedTypes'][$property] = $type; + $this->markEnclosingCollectionsNonUniform(); + } + } + + /** + * Get the declared type a property's value selected on the current node. + */ + public function selectedType(string $property): ?NamedType + { + $override = $this->overrideNodeAtCurrentPath(); + + if ($override !== null && array_key_exists($property, $override['selectedTypes'] ?? [])) { + return $override['selectedTypes'][$property]; + } + + return $this->structureNodeAtCurrentPath()['selectedTypes'][$property] ?? null; + } + /** * Record one automatic lazy property recipe. */ @@ -544,6 +788,7 @@ public function snapshotForProperty(string $property): self $payload = $this->payloadAtCurrentPath(); $snapshot->payload = self::payloadSkeleton($this->path(), $payload); $snapshot->unknownInput = null; + $snapshot->ruleInferrers = []; $templatePath = []; $exactPath = []; @@ -700,6 +945,10 @@ private function pruneStructureNode(array $node, array $paths, string $property) $pruned['mappings'][$property] = $node['mappings'][$property]; } + if (array_key_exists($property, $node['selectedTypes'] ?? [])) { + $pruned['selectedTypes'][$property] = $node['selectedTypes'][$property]; + } + if (array_key_exists($property, $node['children'])) { $pruned['children'][$property] = $node['children'][$property]; } @@ -876,8 +1125,11 @@ private function pathContainsItem(): bool * class: null|class-string, * mappings: array, * children: array, + * selectedTypes?: array, * autoLazy?: array, * paginatorSource?: AbstractCursorPaginator|AbstractPaginator, + * namedFactory?: array{DataClass, DataMethod, DataMethodMatch}, + * contextualPrepared?: true, * uniform?: false, * items?: array * } diff --git a/src/data/src/Support/Creation/CreationContext.php b/src/data/src/Support/Creation/CreationContext.php index 7e94b23337..82490bfe68 100644 --- a/src/data/src/Support/Creation/CreationContext.php +++ b/src/data/src/Support/Creation/CreationContext.php @@ -39,6 +39,7 @@ public function __construct( public ValidationStrategy $validationStrategy = ValidationStrategy::OnlyRequests, public bool $mapPropertyNames = true, public bool $disableMagicalCreation = false, + public bool $useOptionalValues = true, public array $ignoredMagicalMethods = [], public array $casts = [], public array $normalizers = [], diff --git a/src/data/src/Support/Creation/CreationContextFactory.php b/src/data/src/Support/Creation/CreationContextFactory.php index e583691968..54e53423b3 100644 --- a/src/data/src/Support/Creation/CreationContextFactory.php +++ b/src/data/src/Support/Creation/CreationContextFactory.php @@ -39,6 +39,8 @@ class CreationContextFactory protected bool $disableMagicalCreation = false; + protected bool $useOptionalValues = true; + /** @var list */ protected array $ignoredMagicalMethods = []; @@ -147,8 +149,6 @@ public function withoutPropertyNameMapping(bool $withoutPropertyNameMapping = tr return $this; } - // REMOVED: withOptionalValues()/withoutOptionalValues(); Optional declarations always preserve absence. - /** * Disable or enable named creation methods. */ @@ -171,6 +171,31 @@ public function withMagicalCreation(bool $withMagicalCreation = true): static return $this; } + /** + * Enable or disable Optional values for missing properties. + * + * When disabled, a missing Optional property that also accepts null receives null. One that does not + * accept null still receives Optional. + */ + public function withOptionalValues(bool $withOptionalValues = true): static + { + $this->useOptionalValues = $withOptionalValues; + $this->invalidateCreateContext(); + + return $this; + } + + /** + * Disable or enable Optional values for missing properties. + */ + public function withoutOptionalValues(bool $withoutOptionalValues = true): static + { + $this->useOptionalValues = ! $withoutOptionalValues; + $this->invalidateCreateContext(); + + return $this; + } + /** * Ignore named creation methods for this operation. */ @@ -330,6 +355,7 @@ public function get(CreationMode $mode = CreationMode::Create): CreationContext disableMagicalCreation: $mode === CreationMode::Create ? $this->disableMagicalCreation : true, + useOptionalValues: $this->useOptionalValues, ignoredMagicalMethods: $this->ignoredMagicalMethods, casts: $this->casts, normalizers: $this->normalizers, @@ -404,7 +430,7 @@ public function getValidationRules(array $payload): array * @template TDataCollectionValue of BaseData * @template TModelValue of Model * - * @param AbstractCursorPaginator|AbstractPaginator|array|Collection|CursorPaginatedDataCollection|CursorPaginatorContract|DataCollection|EloquentCollection|Enumerable|LazyCollection|LengthAwarePaginatorContract|PaginatedDataCollection|PaginatorContract|Traversable $items + * @param null|AbstractCursorPaginator|AbstractPaginator|array|Collection|CursorPaginatedDataCollection|CursorPaginatorContract|DataCollection|EloquentCollection|Enumerable|LazyCollection|LengthAwarePaginatorContract|PaginatedDataCollection|PaginatorContract|Traversable $items null collects an empty `$into` target * @param null|'array'|class-string $into * @return ( * $into is null diff --git a/src/data/src/Support/Creation/CreationExtensions.php b/src/data/src/Support/Creation/CreationExtensions.php new file mode 100644 index 0000000000..3d744ca549 --- /dev/null +++ b/src/data/src/Support/Creation/CreationExtensions.php @@ -0,0 +1,222 @@ + */ + protected array $attributeCasts = []; + + /** @var array, Cast> */ + protected array $casts = []; + + /** @var array> */ + protected array $classNormalizers = []; + + /** @var array, Normalizer> */ + protected array $normalizers = []; + + /** @var array */ + protected array $autoLazies = []; + + /** @var array */ + protected array $castableCasts = []; + + /** @var array */ + protected array $dateCasts = []; + + /** @var array */ + protected array $enumCasts = []; + + /** @var array */ + protected array $builtinCasts = []; + + /** + * Create the extensions for one creation operation. + */ + public function __construct( + protected readonly Container $container, + protected readonly DataConfig $config, + ) { + } + + /** + * Get the ordered custom casts applicable to a property. + * + * @return list + */ + public function propertyCasts(DataProperty $property, CreationContext $context): array + { + $casts = []; + + if ($property->cast !== null) { + $key = spl_object_id($property->cast); + + if (! isset($this->attributeCasts[$key])) { + /** @var GetsCast $attribute */ + $attribute = $property->cast->newInstance(); + $this->attributeCasts[$key] = $attribute->get(); + } + + $casts[] = $this->attributeCasts[$key]; + } + + foreach ($context->casts as $baseType => $cast) { + if ($property->type->findAcceptedTypeForBaseType($baseType) !== null) { + $casts[] = $this->cast($cast); + } + } + + foreach ($property->configuredCasts as $cast) { + $casts[] = $this->cast($cast); + } + + return $casts; + } + + /** + * Get the factory and configured casts for one plain iterable type's items. + * + * @return list + */ + public function itemCasts(DataProperty $property, NamedType $type, CreationContext $context): array + { + $casts = []; + + foreach ($context->casts as $baseType => $cast) { + if ($type->iterableItemType->findAcceptedTypeForBaseType($baseType) !== null) { + $casts[] = $this->cast($cast); + } + } + + foreach ($property->configuredItemCasts[$type->name] ?? [] as $cast) { + $casts[] = $this->cast($cast); + } + + return $casts; + } + + /** + * Get the custom normalizers for one data class. + * + * @return list + */ + public function normalizers(DataClass $dataClass, CreationContext $context): array + { + // Avoid building a lookup on the common path with no custom normalizers. + if ($context->normalizers === [] + && $this->config->normalizers === [] + && ! $dataClass->hasLifecycleMethod('normalizers') + ) { + return []; + } + + if (isset($this->classNormalizers[$dataClass->name])) { + return $this->classNormalizers[$dataClass->name]; + } + + $normalizers = []; + + if ($dataClass->hasLifecycleMethod('normalizers')) { + $class = $dataClass->name; + /** @var list|Normalizer> $normalizers */ + $normalizers = $this->container->call($class::normalizers(...)); + } + + array_push($normalizers, ...$context->normalizers, ...$this->config->normalizers); + + foreach ($normalizers as $index => $normalizer) { + if (! $normalizer instanceof Normalizer) { + /** @var Normalizer $resolved */ + $resolved = $this->normalizers[$normalizer] ??= $this->container->make($normalizer); + $normalizers[$index] = $resolved; + } + } + + return $this->classNormalizers[$dataClass->name] = array_values($normalizers); + } + + /** + * Get the automatic lazy attribute instance for a property. + */ + public function autoLazy(DataProperty $property): AutoLazy + { + $attribute = $property->autoLazy; + + /** @var AutoLazy */ + return $this->autoLazies[spl_object_id($attribute)] ??= $attribute->newInstance(); + } + + /** + * Get the cast for a Castable class. + * + * @param class-string $class + */ + public function castableCast(string $class): CastableCast + { + return $this->castableCasts[$class] ??= new CastableCast($class); + } + + /** + * Get the built-in cast for a date type. + */ + public function dateCast(string $type): DateTimeInterfaceCast + { + return $this->dateCasts[$type] ??= new DateTimeInterfaceCast(type: $type); + } + + /** + * Get the built-in cast for a backed enum type. + */ + public function enumCast(string $type): EnumCast + { + return $this->enumCasts[$type] ??= new EnumCast($type); + } + + /** + * Get the built-in cast for a scalar or array type. + */ + public function builtinCast(string $type): BuiltinTypeCast + { + return $this->builtinCasts[$type] ??= new BuiltinTypeCast($type); + } + + /** + * Resolve one cast once for the operation. + * + * @param Cast|class-string $cast + */ + protected function cast(Cast|string $cast): Cast + { + if ($cast instanceof Cast) { + return $cast; + } + + /** @var Cast */ + return $this->casts[$cast] ??= $this->container->make($cast); + } +} diff --git a/src/data/src/Support/Creation/CreationMode.php b/src/data/src/Support/Creation/CreationMode.php index b67b1353b7..7d7e7d65bb 100644 --- a/src/data/src/Support/Creation/CreationMode.php +++ b/src/data/src/Support/Creation/CreationMode.php @@ -4,9 +4,9 @@ namespace Hypervel\Data\Support\Creation; -enum CreationMode +enum CreationMode: string { - case Create; - case Validate; - case Rules; + case Create = 'Create'; + case Validate = 'Validate'; + case Rules = 'Rules'; } diff --git a/src/data/src/Support/Creation/DataCreator.php b/src/data/src/Support/Creation/DataCreator.php index cdb481e147..d30fbde11c 100644 --- a/src/data/src/Support/Creation/DataCreator.php +++ b/src/data/src/Support/Creation/DataCreator.php @@ -11,14 +11,8 @@ use Hypervel\Contracts\Pagination\CursorPaginator as CursorPaginatorContract; use Hypervel\Contracts\Pagination\LengthAwarePaginator as LengthAwarePaginatorContract; use Hypervel\Contracts\Pagination\Paginator as PaginatorContract; -use Hypervel\Data\Attributes\AutoLazy; use Hypervel\Data\Attributes\AutoWhenLoadedLazy; -use Hypervel\Data\Attributes\GetsCast; -use Hypervel\Data\Casts\BuiltinTypeCast; use Hypervel\Data\Casts\Cast; -use Hypervel\Data\Casts\CastableCast; -use Hypervel\Data\Casts\DateTimeInterfaceCast; -use Hypervel\Data\Casts\EnumCast; use Hypervel\Data\Casts\IterableItemCast; use Hypervel\Data\Casts\Uncastable; use Hypervel\Data\Contracts\BaseData; @@ -34,7 +28,6 @@ use Hypervel\Data\Lazy; use Hypervel\Data\Normalizers\Normalized\Normalized; use Hypervel\Data\Normalizers\Normalized\UnknownProperty; -use Hypervel\Data\Normalizers\Normalizer; use Hypervel\Data\Optional; use Hypervel\Data\PaginatedDataCollection; use Hypervel\Data\Support\DataClass; @@ -60,12 +53,30 @@ use function data_set; use function Hypervel\Support\enum_try_from; -/** @phpstan-type OperationMemo array> */ +/** + * Creates data objects by filling, validating, and constructing them over one ConstructionState. + * + * Fill normalizes and prepares the input and records each decision that depends on the raw input: + * selected union types, wire-key mappings, morph classes, automatic lazy and paginator sources, and + * deferred named factories. Validation and construction read those records instead of deciding again + * from normalized values. When a validation hook replaces a value, reconciliation prepares it again. + * + * Beneath a property with an explicit cast, validated input is still normalized and prepared, but named + * factories and object construction wait until the cast declines the value. + */ class DataCreator { /** @var array, CreationContext> */ protected array $defaultContexts = []; + /** + * The empty extensions each operation clones; it is never used directly. + * + * Cloning costs a fraction of a constructor call, which the direct creation path would otherwise pay + * for every object. + */ + protected readonly CreationExtensions $emptyExtensions; + /** * Create a data creator. */ @@ -77,18 +88,32 @@ public function __construct( protected readonly DataInstantiator $instantiator, protected readonly DataValidator $validator, ) { + $this->emptyExtensions = new CreationExtensions($container, $config); } /** * Create a fresh construction factory for a data class. * + * A given creation context supplies its options, but not its hooks, which belong to its own operation. + * * @template TData of BaseData * * @param class-string $class * @return CreationContextFactory */ - public function factory(string $class): CreationContextFactory + public function factory(string $class, ?CreationContext $creationContext = null): CreationContextFactory { + if ($creationContext !== null) { + return (new CreationContextFactory($this, $this->config, $class)) + ->validationStrategy($creationContext->validationStrategy) + ->withPropertyNameMapping($creationContext->mapPropertyNames) + ->withoutMagicalCreation($creationContext->disableMagicalCreation) + ->withOptionalValues($creationContext->useOptionalValues) + ->ignoreMagicalMethod(...$creationContext->ignoredMagicalMethods) + ->withCastCollection($creationContext->casts) + ->withNormalizers(...$creationContext->normalizers); + } + $factory = new CreationContextFactory( $this, $this->config, @@ -166,10 +191,11 @@ public function resolveAutoLazyProperty( mixed $value, ConstructionState $state, ?AutoLazyReplayMode $replay, + ?array $castInputs = null, ): mixed { $class = $state->nodeClass() ?? $state->context->dataClass; $property = $this->dataClasses->get($class)->properties[$propertyName]; - $extensions = []; + $extensions = clone $this->emptyExtensions; if ($replay !== null && ! $property->isFinishedValue($value)) { $wireKey = $state->originalKey($propertyName); @@ -187,7 +213,7 @@ public function resolveAutoLazyProperty( $value = $state->getValue($inputPath); } - return $this->castProperty($property, $value, $state, $extensions); + return $this->castProperty($property, $value, $state, $extensions, [], $castInputs); } /** @@ -200,7 +226,7 @@ public function resolveAutoLazyProperty( * @template TData of BaseData * * @param class-string $class - * @param AbstractCursorPaginator|AbstractPaginator|array|Collection|CursorPaginatedDataCollection|CursorPaginatorContract|DataCollection|EloquentCollection|Enumerable|LazyCollection|LengthAwarePaginatorContract|PaginatedDataCollection|PaginatorContract|Traversable $items + * @param null|AbstractCursorPaginator|AbstractPaginator|array|Collection|CursorPaginatedDataCollection|CursorPaginatorContract|DataCollection|EloquentCollection|Enumerable|LazyCollection|LengthAwarePaginatorContract|PaginatedDataCollection|PaginatorContract|Traversable $items null collects an empty `$into` target * @param null|'array'|class-string $into * @return AbstractCursorPaginator|AbstractPaginator|array|CursorPaginatedDataCollection|CursorPaginatorContract|DataCollection|Enumerable|LengthAwarePaginatorContract|PaginatedDataCollection|PaginatorContract */ @@ -216,7 +242,10 @@ public function collect( return $this->collectLazy($class, $context, $items, $into); } - $values = $this->dataCollectables->items($items); + // A missing collection with an explicit target collects as that empty target. + $values = $items === null && $into !== null + ? [] + : $this->dataCollectables->items($items); if ($values === null) { throw CannotCreateDataCollectable::create( @@ -323,11 +352,11 @@ protected function deferCollectionItems( CreationContext $context, LazyCollection $source, ): LazyCollection { - $extensions = []; + $extensions = clone $this->emptyExtensions; // Deferred traversal remains part of this root operation, so its resolved extensions stay shared. return $source->map( - function (mixed $item) use ($class, $context, &$extensions): BaseData { + function (mixed $item) use ($class, $context, $extensions): BaseData { return $this->createUnvalidatedNode( $class, $context, @@ -345,14 +374,13 @@ function (mixed $item) use ($class, $context, &$extensions): BaseData { * * @param class-string $class * @param array $payloads caller keys remain meaningful until named factory matching - * @param OperationMemo $extensions * @return TData */ protected function createUnvalidatedNode( string $class, CreationContext $context, array $payloads, - array &$extensions, + CreationExtensions $extensions, ): BaseData { if (count($payloads) === 1) { $key = array_key_first($payloads); @@ -456,7 +484,7 @@ protected function createEagerCollectionItems( ? $this->validator->authorize($class, [$source]) : null; $state = ConstructionState::create($context, $class); - $extensions = []; + $extensions = clone $this->emptyExtensions; if ($source instanceof EloquentCollection) { $this->loadMissingRelations($class, $source); @@ -540,13 +568,12 @@ protected function loadMissingRelations(string $class, EloquentCollection $model * * @param class-string $class * @param array $values - * @param OperationMemo $extensions */ protected function fillCollectionItems( string $class, array $values, ConstructionState $state, - array &$extensions, + CreationExtensions $extensions, bool $shouldValidate, ): void { foreach ($values as $key => $item) { @@ -582,13 +609,12 @@ protected function fillCollectionItems( * Cast and instantiate every eager root collection item. * * @param class-string $class - * @param OperationMemo $extensions * @return array */ protected function castCollectionItems( string $class, ConstructionState $state, - array &$extensions, + CreationExtensions $extensions, ): array { $items = []; @@ -635,7 +661,7 @@ protected function execute( && $this->validator->shouldValidate($context, $payloads); $compilesRules = $shouldValidate || $context->mode === CreationMode::Rules; - $extensions = []; + $extensions = clone $this->emptyExtensions; if ($context->mode === CreationMode::Create && count($payloads) === 1 @@ -709,17 +735,38 @@ protected function execute( * * @param class-string $class * @param array $payloads caller keys remain meaningful until named factory matching - * @param OperationMemo $extensions */ protected function fillNode( string $class, array $payloads, ConstructionState $state, - array &$extensions, + CreationExtensions $extensions, bool $shouldValidate, bool $compilesRules, ): ?BaseData { $dataClass = $this->dataClasses->get($class); + + if ($state->executionDeferred()) { + // Beneath a cast-owned value, the input is prepared and validated as usual, while a matching named + // factory waits to run with the input it matched until the cast declines. A match is no exemption + // from validation. + $match = $this->matchNamedFactory($dataClass, $state->context, $payloads); + $filled = $this->fillGeneralNode( + $dataClass, + array_values($payloads), + $state, + $extensions, + $shouldValidate, + $compilesRules, + ); + + if ($filled && $match !== null) { + $state->recordNamedFactory($dataClass, ...$match); + } + + return null; + } + $payloads = $this->resolveFactoryPayloads($dataClass, $state->context, $payloads); if ($payloads instanceof BaseData) { @@ -755,59 +802,97 @@ protected function fillNode( * Fill one post-factory value through the general construction path. * * @param array $payloads - * @param OperationMemo $extensions + * @return bool false when nested input could not be read and was kept as given */ protected function fillGeneralNode( DataClass $dataClass, array $payloads, ConstructionState $state, - array &$extensions, + CreationExtensions $extensions, bool $shouldValidate, bool $compilesRules, - ): void { + ): bool { $class = $dataClass->name; - $normalizers = $this->resolveNormalizers($dataClass, $state->context, $extensions); + $normalizers = $extensions->normalizers($dataClass, $state->context); $payloads = $payloads === [] ? [[]] : $payloads; $sources = []; - $unknownInputSources = []; + // Requests read by fixed handling, keyed by source position, check unknown fields against their body. + $fixedRequests = []; foreach ($payloads as $payload) { - $source = SourceResolver::resolve($class, $payload, $normalizers); + $source = SourceResolver::normalize($payload, $normalizers); + + if ($source === null && $payload instanceof Request) { + $fixedRequests[count($sources)] = $payload; + } + + $source ??= SourceResolver::resolve($payload); + + if ($source === null) { + // Nested input Fill cannot read stays as given, so validation reports it. Beneath a cast, + // the cast or a named factory may still read it. + if ($state->executionDeferred() || ($compilesRules && $state->depth() > 0)) { + $state->writeNodeValue($payload); + + return false; + } + + throw CannotCreateData::noNormalizerFound($class, $payload); + } + $sources[] = $source; - $unknownInputSources[] = $payload instanceof Request - ? ($payload->isJson() ? $payload->json()->all() : $payload->request->all()) - : $source; } $propertySources = $sources; - $resolvedProperties = $this->resolveProperties($dataClass, $propertySources, $state->context); + $resolvedProperties = $this->resolveProperties($dataClass, $propertySources, $state->context, $compilesRules); if ($state->context->prepareDataHooks !== []) { - $input = $this->mergeSources($dataClass, $propertySources, $state->context); + $input = $this->prepareDataInput($dataClass, $propertySources, $resolvedProperties, $state->context, $compilesRules); foreach ($state->context->prepareDataHooks as $hook) { $input = $hook($input); } $propertySources = [$input]; - $resolvedProperties = $this->resolveProperties($dataClass, $propertySources, $state->context); + $resolvedProperties = $this->resolveProperties($dataClass, $propertySources, $state->context, $compilesRules); } - $class = $this->resolveMorphClass($dataClass, $resolvedProperties); + $class = $this->resolveMorphClass($dataClass, $resolvedProperties, $compilesRules); if ($class !== $dataClass->name) { $dataClass = $this->dataClasses->get($class); - $resolvedProperties = $this->resolveProperties($dataClass, $propertySources, $state->context); + $resolvedProperties = $this->resolveProperties($dataClass, $propertySources, $state->context, $compilesRules); + } + + if ($dataClass->hasLifecycleMethod('prepareForPipeline')) { + $propertySources = $this->prepareSourcesForPipeline($dataClass, $propertySources, $state->context, $compilesRules); + $resolvedProperties = $this->resolveProperties($dataClass, $propertySources, $state->context, $compilesRules); + } + + if ($compilesRules) { + // Rules may read undeclared input, such as a confirmation field or a condition. + $state->writeNodeInput($this->mergeArraySources($propertySources)); } $state->setNodeClass($class); if ($shouldValidate && $dataClass->failOnUnknownFields) { + $unknownInputSources = $sources; + + foreach ($fixedRequests as $index => $request) { + $unknownInputSources[$index] = $request->isJson() ? $request->json()->all() : $request->request->all(); + } + $state->recordUnknownInput( - $this->mergeSources($dataClass, $unknownInputSources, $state->context), + $this->mergeSources($dataClass, $unknownInputSources, $state->context, $compilesRules), ); } + // Checked here so the common path, with no constructor inputs, skips the call. + if ($dataClass->constructorInputs !== []) { + $this->fillConstructorInputs($dataClass, $propertySources, $state); + } + $this->fillResolvedProperties( $dataClass, $resolvedProperties, @@ -819,6 +904,8 @@ protected function fillGeneralNode( $compilesRules, false, ); + + return true; } /** @@ -828,13 +915,12 @@ protected function fillGeneralNode( * nested factory, hook, or constructor when the caller enters general Fill. * * @param array $payloads - * @param OperationMemo $extensions */ protected function tryCreateDirectArrayNode( DataClass $dataClass, array $payloads, CreationContext $context, - array &$extensions, + CreationExtensions $extensions, bool $shouldValidate, bool $compilesRules, ): ?BaseData { @@ -885,11 +971,12 @@ protected function tryCreateDirectArrayNode( continue; } - if ($property->hasDefaultValue) { + // Absent properties outside the constructor are resolved after construction, keeping any value it assigned. + if ($property->hasDefaultValue || ! $property->isConstructorParameter) { continue; } - if ($property->type->isOptional) { + if ($property->type->isOptional && ($context->useOptionalValues || ! $property->type->isNullable)) { $properties[$property->name] = Optional::create(); continue; @@ -907,6 +994,10 @@ protected function tryCreateDirectArrayNode( } if ($property->computed) { + if ($this->config->ignoreComputedPropertyInput) { + continue; + } + throw CannotSetComputedValue::create($property); } @@ -950,44 +1041,61 @@ protected function tryCreateDirectArrayNode( return $dataClass->directConstructorInstantiation && ! $requiresOrdinaryInstantiation ? $this->instantiator->instantiateDirect($dataClass, $properties) - : $this->instantiator->instantiate($dataClass, $properties); + : $this->instantiator->instantiate($dataClass, $properties, $context->useOptionalValues); } /** * Fill one node introduced or changed by a validation payload hook. * * @param class-string $class - * @param OperationMemo $extensions */ protected function fillHookNode( string $class, mixed $payload, ConstructionState $state, - array &$extensions, + CreationExtensions $extensions, bool $shouldValidate, bool $compilesRules, ): ?BaseData { $dataClass = $this->dataClasses->get($class); + $deferred = $state->executionDeferred(); $match = $this->matchNamedFactory($dataClass, $state->context, [$payload]); - if ($match !== null) { - $payload = $this->invokeNamedFactory($dataClass, ...$match); + if ($match !== null && ! $deferred) { + return $this->invokeNamedObjectFactory($dataClass, ...$match); + } + + $source = SourceResolver::resolve($payload); + + if ($source === null) { + // Nested input Fill cannot read stays as given, so validation reports it. Beneath a cast, + // the cast or a named factory may still read it. + if ($deferred || ($compilesRules && $state->depth() > 0)) { + $state->writeNodeValue($payload); - if ($payload instanceof $class) { - return $payload; + return null; } + + throw CannotCreateData::noNormalizerFound($class, $payload); } - $source = SourceResolver::resolve($class, $payload, []); - $resolvedProperties = $this->resolveProperties($dataClass, [$source], $state->context); - $class = $this->resolveMorphClass($dataClass, $resolvedProperties); + if ($match !== null) { + $state->recordNamedFactory($dataClass, ...$match); + } + $resolvedProperties = $this->resolveProperties($dataClass, [$source], $state->context, $compilesRules); + $class = $this->resolveMorphClass($dataClass, $resolvedProperties, $compilesRules); if ($class !== $dataClass->name) { $dataClass = $this->dataClasses->get($class); - $resolvedProperties = $this->resolveProperties($dataClass, [$source], $state->context); + $resolvedProperties = $this->resolveProperties($dataClass, [$source], $state->context, $compilesRules); + } + + if ($compilesRules && is_array($source)) { + $state->writeNodeInput($source); } $state->setNodeClass($class); + $this->fillConstructorInputs($dataClass, [$source], $state); $this->fillResolvedProperties( $dataClass, $resolvedProperties, @@ -1003,13 +1111,31 @@ protected function fillHookNode( return null; } + /** + * Write the raw input of constructor parameters that have no public data property. + * + * @param list|Normalized> $sources + */ + protected function fillConstructorInputs(DataClass $dataClass, array $sources, ConstructionState $state): void + { + foreach ($dataClass->constructorInputs as $input) { + // Later sources win; normalized sources such as models provide only declared properties. + for ($index = count($sources) - 1; $index >= 0; --$index) { + if (is_array($sources[$index]) && array_key_exists($input, $sources[$index])) { + $state->writePropertyValue([$input], $sources[$index][$input]); + + break; + } + } + } + } + /** * Write one resolved data node and recursively fill its declared children. * * @param array $resolvedProperties * @param list $sources * @param list $payloads - * @param OperationMemo $extensions */ protected function fillResolvedProperties( DataClass $dataClass, @@ -1017,19 +1143,35 @@ protected function fillResolvedProperties( array $sources, array $payloads, ConstructionState $state, - array &$extensions, + CreationExtensions $extensions, bool $shouldValidate, bool $compilesRules, bool $fromValidationHook, ): void { $contextualParameters = $dataClass->contextualParameters; + $contextualValues = null; + + if ($compilesRules && $contextualParameters !== []) { + // Promoted contextual values are validated with the object, so they are resolved once here and replace + // any input. Constructor-only parameters are not validated and stay with construction. + $promoted = array_keys(array_intersect_key($contextualParameters, $dataClass->properties)); + + if ($promoted !== []) { + $contextualValues = $this->instantiator->resolveContextualParameters($dataClass, $promoted); + $state->markContextualValuesPrepared(); + } + } foreach ($dataClass->properties as $property) { [$wireKey, $value] = $resolvedProperties[$property->name]; $state->recordMapping($property->name, $wireKey); if (isset($contextualParameters[$property->name])) { - continue; + if ($contextualValues === null) { + continue; + } + + $value = $contextualValues[$property->name]; } $inputPath = $property->inputPath($wireKey); @@ -1041,13 +1183,14 @@ protected function fillResolvedProperties( $sources, $payloads, $state->context, + $compilesRules, ); $state->recordAutoLazy($property->name, $autoLazySource); } if ($value instanceof UnknownProperty) { if ($property->autoLazy !== null - && $this->requiresAutoLazyReplay($property) + && $this->requiresAutoLazyReplay($property, $state->context, $extensions) && ($property->hasDefaultValue || $this->isAutoWhenLoaded($property)) ) { $state->recordAutoLazy( @@ -1063,6 +1206,10 @@ protected function fillResolvedProperties( } if ($property->computed) { + if ($this->config->ignoreComputedPropertyInput) { + continue; + } + throw CannotSetComputedValue::create($property); } @@ -1074,7 +1221,7 @@ protected function fillResolvedProperties( if ($property->autoLazy !== null && (! $compilesRules || ! $property->validate) - && $this->requiresAutoLazyReplay($property) + && $this->requiresAutoLazyReplay($property, $state->context, $extensions) && $value !== null && ! $value instanceof Optional && ! $value instanceof Lazy @@ -1107,7 +1254,6 @@ protected function fillResolvedProperties( /** * Write one resolved property and recursively fill its declared children. * - * @param OperationMemo $extensions * @param non-empty-list $inputPath */ protected function fillResolvedProperty( @@ -1115,15 +1261,47 @@ protected function fillResolvedProperty( array $inputPath, mixed $value, ConstructionState $state, - array &$extensions, + CreationExtensions $extensions, bool $shouldValidate, bool $compilesRules, bool $fromValidationHook, ): void { - $dataIterable = $property->type->getDataCollectableType(); - $typedIterable = $dataIterable === null - ? $property->type->getNonDataIterableType() - : null; + if ($compilesRules + && $property->type->isDataRelated + && ! $state->executionDeferred() + && $this->isCastOwned($property, $state->context, $extensions) + ) { + // Rules beneath a cast check the value's prepared input, so the value is filled as usual, + // but named factories and constructors wait until the cast declines. + $state->setExecutionDeferred(true); + + try { + $this->fillResolvedProperty( + $property, + $inputPath, + $value, + $state, + $extensions, + $shouldValidate, + $compilesRules, + $fromValidationHook, + ); + } finally { + $state->setExecutionDeferred(false); + } + + return; + } + + // Every property passes through here, so the common cases avoid the selection and cast lookups. + if ($property->type->recordsSelectedType) { + $selectedType = $this->selectType($property, $value, $state); + $dataIterable = $selectedType?->kind->isDataCollectable() ? $selectedType : null; + $typedIterable = $dataIterable === null && $selectedType?->iterableItemType !== null ? $selectedType : null; + } else { + $dataIterable = $property->type->getDataCollectableType(); + $typedIterable = $dataIterable === null ? $property->type->getNonDataIterableType() : null; + } if ($dataIterable !== null || $typedIterable !== null) { $this->retainPaginatorSource( @@ -1135,6 +1313,16 @@ protected function fillResolvedProperty( ); } + if (! $compilesRules + && $property->type->isDataRelated + && $this->isCastOwned($property, $state->context, $extensions) + ) { + // Without validation, the cast receives the value exactly as given. + $state->writePropertyValue($inputPath, $value); + + return; + } + if ($dataIterable !== null && $value instanceof LazyCollection) { if (! $compilesRules) { $state->writePropertyValue($inputPath, $value); @@ -1250,13 +1438,12 @@ protected function fillResolvedProperty( * * @param class-string $class * @param list $hooks - * @param OperationMemo $extensions */ protected function applyPayloadHooks( string $class, array $hooks, ConstructionState $state, - array &$extensions, + CreationExtensions $extensions, bool $shouldValidate, bool $compilesRules, bool $reconcile = true, @@ -1306,14 +1493,13 @@ protected function applyPayloadHooks( * @param class-string $class * @param array $previousPayload * @param array $payload - * @param OperationMemo $extensions */ protected function reconcileCollection( string $class, array $previousPayload, array $payload, ConstructionState $state, - array &$extensions, + CreationExtensions $extensions, bool $shouldValidate, bool $compilesRules, ): void { @@ -1389,14 +1575,13 @@ protected function reconcileCollection( * @param class-string $declaredClass * @param array $previousPayload * @param array $payload - * @param OperationMemo $extensions */ protected function reconcileNode( string $declaredClass, array $previousPayload, array $payload, ConstructionState $state, - array &$extensions, + CreationExtensions $extensions, bool $shouldValidate, bool $compilesRules, ): void { @@ -1405,17 +1590,19 @@ protected function reconcileNode( $declaredDataClass, [$payload], $state->context, + $compilesRules, ); - $class = $this->resolveMorphClass($declaredDataClass, $declaredProperties); + $class = $this->resolveMorphClass($declaredDataClass, $declaredProperties, $compilesRules); $previousClass = $state->nodeClass() ?? $declaredClass; if ($class !== $previousClass) { $dataClass = $this->dataClasses->get($class); $resolvedProperties = $class === $declaredClass ? $declaredProperties - : $this->resolveProperties($dataClass, [$payload], $state->context); + : $this->resolveProperties($dataClass, [$payload], $state->context, $compilesRules); $state->resetNodeStructure(); $state->setNodeClass($class); + $this->rematchDeferredFactory($declaredDataClass, $payload, $state); $this->fillResolvedProperties( $dataClass, $resolvedProperties, @@ -1431,30 +1618,48 @@ protected function reconcileNode( return; } + $this->rematchDeferredFactory($declaredDataClass, $payload, $state); $dataClass = $this->dataClasses->get($class); $resolvedProperties = $class === $declaredClass ? $declaredProperties - : $this->resolveProperties($dataClass, [$payload], $state->context); + : $this->resolveProperties($dataClass, [$payload], $state->context, $compilesRules); $contextualParameters = $dataClass->contextualParameters; + $contextualPrepared = $contextualParameters !== [] && $state->contextualValuesPrepared(); foreach ($dataClass->properties as $property) { $previousWireKey = $state->originalKey($property->name); - $previousValue = SourceReader::read( - $previousPayload, - $property->inputPath($previousWireKey), - $property, - ); + $previousInputPath = $property->inputPath($previousWireKey); + $previousValue = SourceReader::read($previousPayload, $previousInputPath, $property); + + if (isset($contextualParameters[$property->name])) { + // A prepared contextual value keeps its pre-hook value, including its absence after validation + // excluded it, so a hook can neither replace nor restore it. + if ($contextualPrepared + && SourceReader::read($payload, $previousInputPath, $property) !== $previousValue + ) { + $previousValue instanceof UnknownProperty + ? $state->forgetPropertyValue($previousInputPath) + : $state->writePropertyValue($previousInputPath, $previousValue); + } + + continue; + } + [$wireKey, $value] = $resolvedProperties[$property->name]; if ($wireKey !== $previousWireKey) { $state->replaceMapping($property->name, $wireKey); } - if ($value === $previousValue || isset($contextualParameters[$property->name])) { + if ($value === $previousValue) { continue; } if (! $value instanceof UnknownProperty && $property->computed) { + if ($this->config->ignoreComputedPropertyInput) { + continue; + } + throw CannotSetComputedValue::create($property); } @@ -1473,9 +1678,28 @@ protected function reconcileNode( } /** - * Reconcile one changed property selection. + * Match the named factory again for a node beneath a cast whose input a validation hook replaced. + * + * A recorded match holds the earlier input, so it no longer describes the node. * - * @param OperationMemo $extensions + * @param array $payload + */ + protected function rematchDeferredFactory(DataClass $dataClass, array $payload, ConstructionState $state): void + { + if (! $state->executionDeferred()) { + return; + } + + $state->forgetNamedFactory(); + $match = $this->matchNamedFactory($dataClass, $state->context, [$payload]); + + if ($match !== null) { + $state->recordNamedFactory($dataClass, ...$match); + } + } + + /** + * Reconcile one changed property selection. */ protected function reconcileProperty( DataProperty $property, @@ -1484,10 +1708,37 @@ protected function reconcileProperty( string|int $wireKey, array $payload, ConstructionState $state, - array &$extensions, + CreationExtensions $extensions, bool $shouldValidate, bool $compilesRules, ): void { + if ($compilesRules + && $property->type->isDataRelated + && ! $state->executionDeferred() + && $this->isCastOwned($property, $state->context, $extensions) + ) { + // As in Fill, a replaced value beneath a cast is prepared for its rules, but nothing runs until the cast declines. + $state->setExecutionDeferred(true); + + try { + $this->reconcileProperty( + $property, + $previousValue, + $value, + $wireKey, + $payload, + $state, + $extensions, + $shouldValidate, + $compilesRules, + ); + } finally { + $state->setExecutionDeferred(false); + } + + return; + } + $inputPath = $property->inputPath($wireKey); $autoLazySource = null; @@ -1497,18 +1748,27 @@ protected function reconcileProperty( [$payload], [$payload], $state->context, + $compilesRules, ); $state->recordAutoLazy($property->name, $autoLazySource); } - $dataIterable = $property->type->getDataCollectableType(); - $typedIterable = $dataIterable === null - ? $property->type->getNonDataIterableType() - : null; + if ($property->type->recordsSelectedType) { + $selectedType = $this->selectType($property, $value, $state); + $dataIterable = $selectedType?->kind->isDataCollectable() ? $selectedType : null; + $typedIterable = $dataIterable === null && $selectedType?->iterableItemType !== null ? $selectedType : null; + + // A changed union value may select another type, so its children are rebuilt. + $state->clearChildStructure($property->name); + $previousValue = UnknownProperty::create(); + } else { + $dataIterable = $property->type->getDataCollectableType(); + $typedIterable = $dataIterable === null ? $property->type->getNonDataIterableType() : null; + } if ($property->autoLazy !== null && (! $compilesRules || ! $property->validate) - && $this->requiresAutoLazyReplay($property) + && $this->requiresAutoLazyReplay($property, $state->context, $extensions) && ( ($value instanceof UnknownProperty && ($property->hasDefaultValue || $this->isAutoWhenLoaded($property))) @@ -1539,6 +1799,17 @@ protected function reconcileProperty( ); } + if (! $compilesRules && $this->isCastOwned($property, $state->context, $extensions)) { + // Without validation, the cast receives the hook's value exactly as given. + $state->clearChildStructure($property->name); + + if ($property->isFinishedValue($value)) { + $state->writeFinishedPropertyValue($inputPath, $value); + } + + return; + } + if ($dataIterable !== null) { $this->reconcileDataIterable( $property, @@ -1623,7 +1894,6 @@ protected function reconcileProperty( /** * Reconcile one changed typed data iterable. * - * @param OperationMemo $extensions * @param non-empty-list $inputPath */ protected function reconcileDataIterable( @@ -1633,7 +1903,7 @@ protected function reconcileDataIterable( mixed $value, array $inputPath, ConstructionState $state, - array &$extensions, + CreationExtensions $extensions, bool $shouldValidate, bool $compilesRules, ): void { @@ -1740,37 +2010,77 @@ protected function reconcileDataIterable( /** * Cast and instantiate the node at the current construction path. - * - * @param OperationMemo $extensions */ protected function castAndInstantiateNode( ConstructionState $state, - array &$extensions, + CreationExtensions $extensions, ): BaseData { + // A named factory that matched beneath a cast runs only now, after that cast declined. + $namedFactory = $state->namedFactory(); + + if ($namedFactory !== null) { + return $this->invokeNamedObjectFactory(...$namedFactory); + } + $class = $state->nodeClass() ?? $state->context->dataClass; $dataClass = $this->dataClasses->get($class); + + // Validation keeps an unresolved morph for its rules; one that passed still cannot be created. + if ($dataClass->isAbstract) { + throw CannotCreateAbstractClass::morphClassWasNotResolved($class); + } + $properties = []; + // The declared values given to user casts, built on the first one that needs them. + $castInputs = null; $contextualParameters = $dataClass->contextualParameters; + // Prepared contextual values were filled and validated with the node, so they are cast from its state. + $contextualPrepared = $contextualParameters !== [] && $state->contextualValuesPrepared(); foreach ($dataClass->properties as $property) { - if (isset($contextualParameters[$property->name])) { + $contextual = isset($contextualParameters[$property->name]); + + if ($property->computed || ($contextual && ! $contextualPrepared)) { continue; } $wireKey = $state->originalKey($property->name); $inputPath = $property->inputPath($wireKey); + $value = $state->read($inputPath); + + if ($value instanceof UnknownProperty) { + if ($contextual) { + // Validation excluded the value. The container would resolve an absent contextual parameter + // again, so the ordinary missing value is passed explicitly. + $properties[$property->name] = match (true) { + $property->hasDefaultValue => $this->propertyDefaultValue($dataClass, $property), + $property->type->isOptional + && ($state->context->useOptionalValues || ! $property->type->isNullable) => Optional::create(), + $property->type->isNullable => null, + default => throw CannotCreateData::propertyMissing($dataClass, $property), + }; + + continue; + } - if (! $state->hasValue($inputPath)) { if ($property->autoLazy !== null && $property->hasDefaultValue) { - $value = $this->propertyDefaultValue($dataClass, $property); + $value = $castInputs !== null && array_key_exists($property->name, $castInputs) + ? $castInputs[$property->name] + : $this->propertyDefaultValue($dataClass, $property); $state->writePropertyValue($inputPath, $value); $properties[$property->name] = $this->buildAutoLazy( $property, $value, $state, $extensions, + $properties, + $castInputs, ); + if ($castInputs !== null) { + $castInputs[$property->name] = $properties[$property->name]; + } + continue; } @@ -1780,16 +2090,25 @@ protected function castAndInstantiateNode( UnknownProperty::create(), $state, $extensions, + $properties, + $castInputs, ); + if ($castInputs !== null) { + $castInputs[$property->name] = $properties[$property->name]; + } + continue; } - if ($property->hasDefaultValue) { + // Absent properties outside the constructor are resolved after construction, keeping any value it assigned. + if ($property->hasDefaultValue || ! $property->isConstructorParameter) { continue; } - if ($property->type->isOptional) { + if ($property->type->isOptional + && ($state->context->useOptionalValues || ! $property->type->isNullable) + ) { $properties[$property->name] = Optional::create(); } elseif ($property->type->isNullable) { $properties[$property->name] = null; @@ -1798,17 +2117,70 @@ protected function castAndInstantiateNode( continue; } - $value = $state->getValue($inputPath); $properties[$property->name] = $property->autoLazy === null - ? $this->castProperty($property, $value, $state, $extensions) - : $this->buildAutoLazy($property, $value, $state, $extensions); + ? $this->castProperty($property, $value, $state, $extensions, $properties, $castInputs) + : $this->buildAutoLazy($property, $value, $state, $extensions, $properties, $castInputs); + + if ($castInputs !== null && ! $contextual) { + $castInputs[$property->name] = $properties[$property->name]; + } } + if ($castInputs !== null) { + // A default given to a user cast is the same instance the object is constructed with. + foreach ($dataClass->properties as $property) { + if ($property->hasDefaultValue + && ! array_key_exists($property->name, $properties) + && array_key_exists($property->name, $castInputs) + ) { + $properties[$property->name] = $castInputs[$property->name]; + } + } + } + + foreach ($dataClass->constructorInputs as $input) { + $value = $state->read([$input]); + + if (! $value instanceof UnknownProperty) { + $properties[$input] = $value; + } + } + + $preparedContextualValues = $contextualPrepared + ? array_intersect_key($properties, $contextualParameters) + : []; + foreach ($state->context->beforeCreationHooks as $hook) { $properties = $hook($properties); } - $data = $this->instantiator->instantiate($dataClass, $properties); + if ($contextualPrepared) { + // Hooks cannot replace validated contextual values. Constructor-only parameters are resolved now. + $properties = [...$properties, ...$preparedContextualValues]; + $constructorOnly = array_keys(array_diff_key($contextualParameters, $dataClass->properties)); + + if ($constructorOnly !== []) { + foreach ($this->instantiator->resolveContextualParameters($dataClass, $constructorOnly) as $name => $value) { + $properties[$name] = $value; + } + } + } elseif ($contextualParameters !== []) { + // A node not prepared for validation resolves its contextual values after the hooks so they always win, + // and a promoted property's value is prepared and cast like unvalidated input. Null stays null. + foreach ($this->instantiator->resolveContextualParameters($dataClass) as $name => $value) { + $property = $dataClass->properties[$name] ?? null; + + if ($property !== null && $value !== null) { + $inputPath = $property->inputPath($state->originalKey($name)); + $this->fillResolvedProperty($property, $inputPath, $value, $state, $extensions, false, false, false); + $value = $this->castProperty($property, $state->getValue($inputPath), $state, $extensions, $properties, $castInputs); + } + + $properties[$name] = $value; + } + } + + $data = $this->instantiator->instantiate($dataClass, $properties, $state->context->useOptionalValues); foreach ($state->context->afterCreationHooks as $hook) { $data = $hook($data); @@ -1823,28 +2195,35 @@ protected function castAndInstantiateNode( /** * Build one automatic lazy property around the ordinary cast path. - * - * @param OperationMemo $extensions */ protected function buildAutoLazy( DataProperty $property, mixed $value, ConstructionState $state, - array &$extensions, + CreationExtensions $extensions, + array $properties = [], + ?array &$castInputs = null, ): mixed { if ($value === null || $value instanceof Optional || $value instanceof Lazy) { return $value; } + // A deferred user cast sees the declared values as they are now, like a non-lazy cast would. + if ($castInputs === null && $this->requiresCastInputs($property, $state->context, $extensions)) { + $castInputs = $this->castInputs($state, $properties); + } + /** @var array{source: mixed, replay?: AutoLazyReplayMode} $recipe */ $recipe = $state->autoLazy($property->name); $snapshot = $state->snapshotForProperty($property->name); $propertyName = $property->name; $replay = $recipe['replay'] ?? null; + $inputs = $castInputs; $castValue = static function (mixed $resolvedValue) use ( $propertyName, $replay, $snapshot, + $inputs, ): mixed { $state = clone $snapshot; /** @var self $creator */ @@ -1855,10 +2234,11 @@ protected function buildAutoLazy( $resolvedValue, $state, $replay, + $inputs, ); }; - return $this->resolveAutoLazy($property, $extensions)->build( + return $extensions->autoLazy($property)->build( $castValue, $recipe['source'], $property, @@ -1868,14 +2248,14 @@ protected function buildAutoLazy( /** * Cast one supplied property value. - * - * @param OperationMemo $extensions */ protected function castProperty( DataProperty $property, mixed $value, ConstructionState $state, - array &$extensions, + CreationExtensions $extensions, + array $properties = [], + ?array &$castInputs = null, ): mixed { if ($value === null || $value instanceof Optional) { return $value; @@ -1885,17 +2265,29 @@ protected function castProperty( return $value; } - $dataCollectableTypes = $property->type->getDataCollectableTypes(); - $dataIterable = $property->type->getDataCollectableType(); + if ($property->type->recordsSelectedType) { + $selectedType = $state->selectedType($property->name); + $dataIterable = $selectedType?->kind->isDataCollectable() ? $selectedType : null; + $iterable = $dataIterable === null && $selectedType?->iterableItemType !== null ? $selectedType : null; + } else { + $selectedType = null; + $dataIterable = $property->type->getDataCollectableType(); + $iterable = $property->type->getNonDataIterableType(); + } + $shouldCast = ! is_object($value) - || $dataCollectableTypes !== [] + || $property->type->getDataCollectableTypes() !== [] || ! $property->type->acceptsValue($value); $casts = $shouldCast - ? $this->propertyCasts($property, $state->context, $extensions) + ? $extensions->propertyCasts($property, $state->context) : []; + if ($casts !== []) { + $castInputs ??= $this->castInputs($state, $properties); + } + foreach ($casts as $cast) { - $casted = $cast->cast($property, $value, $state, $state->context); + $casted = $cast->cast($property, $value, $castInputs, $state->context); if (! $casted instanceof Uncastable) { return $casted; @@ -1906,42 +2298,76 @@ protected function castProperty( return $this->castDataIterable($property, $dataIterable, $value, $state, $extensions); } - $iterable = $property->type->getNonDataIterableType(); - if ($iterable !== null) { - return $this->castTypedIterable($property, $iterable, $value, $state, $extensions, $casts); + return $this->castTypedIterable( + $property, + $iterable, + $value, + $state, + $extensions, + $casts, + $properties, + $castInputs, + ); } - if (count($dataCollectableTypes) > 1) { - foreach ($property->type->getNamedTypes() as $type) { - if (! $type->kind->isDataRelated() && $type->acceptsValue($value)) { - return $value; + if ($property->type->recordsSelectedType) { + if ($selectedType === null) { + foreach ($property->type->getNamedTypes() as $type) { + if (! $type->kind->isDataRelated() + && ! $type->kind->isNonDataIterable() + && $type->acceptsValue($value) + ) { + return $value; + } } - } - $candidates = []; + if ($property->type->hasContainerUnion) { + $candidates = []; - foreach ($dataCollectableTypes as $type) { - $candidates[] = "{$type->name}<{$type->dataClass}>"; - } - - throw CannotCreateData::ambiguousDataCollectableUnion($property, $candidates); - } + foreach ($property->type->getContainerTypes() as $type) { + $candidates[] = $type->dataClass === null ? $type->name : "{$type->name}<{$type->dataClass}>"; + } - // The lean recipe relies on accepted values passing before the fallback conversions below. - if ($property->type->acceptsValue($value)) { + throw CannotCreateData::ambiguousContainerUnion($property, $candidates); + } + } elseif (! $selectedType->kind->isDataObject()) { + // A selected container type without item metadata keeps the value it accepted. + return $value; + } + } elseif ($property->type->acceptsValue($value)) { + // The lean recipe relies on accepted values passing before the fallback conversions below. return $value; } - $wireKey = $state->originalKey($property->name); - $state->enterProperty($property->name, $property->inputPath($wireKey)); + $dataObjectClass = $property->type->recordsSelectedType + ? $selectedType?->dataClass + : $property->type->getDataObjectClass(); - try { - if ($state->nodeClass() !== null) { - return $this->castAndInstantiateNode($state, $extensions); + if ($dataObjectClass !== null) { + $wireKey = $state->originalKey($property->name); + $state->enterProperty($property->name, $property->inputPath($wireKey)); + + try { + if ($state->nodeClass() !== null) { + return $this->castAndInstantiateNode($state, $extensions); + } + } finally { + $state->leave(); } - } finally { - $state->leave(); + + // Without validation, a value its casts declined was stored as given, so it is created in full. + if ($casts !== []) { + return $this->createUnvalidatedNode($dataObjectClass, $state->context, [$value], $extensions); + } + } + + // The only container type left here has no item metadata, but it still converts between + // collection shapes, such as an array into a Collection. + $containerTypes = $property->type->getContainerTypes(); + + if ($containerTypes !== [] && ($values = $this->iterableValues($value)) !== null) { + return $this->dataCollectables->forProperty($containerTypes[0], $values, $state); } $dataObjectTypes = $property->type->getDataObjectTypes(); @@ -1963,10 +2389,9 @@ protected function castProperty( continue; } - $key = 'castable:' . $type->name; - /** @var CastableCast $cast */ - $cast = $extensions[$key] ??= new CastableCast($type->name); - $casted = $cast->cast($property, $value, $state, $state->context); + $cast = $extensions->castableCast($type->name); + $castInputs ??= $this->castInputs($state, $properties); + $casted = $cast->cast($property, $value, $castInputs, $state->context); if (! $casted instanceof Uncastable) { return $casted; @@ -1976,14 +2401,11 @@ protected function castProperty( $dateType = $property->type->findAcceptedTypeForBaseType(DateTimeInterface::class); if ($dateType !== null) { - $key = 'date:' . $dateType; - /** @var DateTimeInterfaceCast $cast */ - $cast = $extensions[$key] ??= new DateTimeInterfaceCast(type: $dateType); - - return $cast->cast( + // The fixed built-in casts ignore sibling values, so none are built for them. + return $extensions->dateCast($dateType)->cast( $property, $value, - $state, + [], $state->context, ); } @@ -1991,14 +2413,10 @@ protected function castProperty( $enumType = $property->type->findAcceptedTypeForBaseType(BackedEnum::class); if ($enumType !== null) { - $key = 'enum:' . $enumType; - /** @var EnumCast $cast */ - $cast = $extensions[$key] ??= new EnumCast($enumType); - - return $cast->cast( + return $extensions->enumCast($enumType)->cast( $property, $value, - $state, + [], $state->context, ); } @@ -2006,14 +2424,10 @@ protected function castProperty( $builtin = $property->type->type->getSingleBuiltinType(); if ($builtin !== null) { - $key = 'builtin:' . $builtin; - /** @var BuiltinTypeCast $cast */ - $cast = $extensions[$key] ??= new BuiltinTypeCast($builtin); - - return $cast->cast( + return $extensions->builtinCast($builtin)->cast( $property, $value, - $state, + [], $state->context, ); } @@ -2023,15 +2437,13 @@ protected function castProperty( /** * Cast every item in one declared data iterable. - * - * @param OperationMemo $extensions */ protected function castDataIterable( DataProperty $property, NamedType $type, mixed $value, ConstructionState $state, - array &$extensions, + CreationExtensions $extensions, ): mixed { /** @var class-string $dataClass */ $dataClass = $type->dataClass; @@ -2041,7 +2453,7 @@ protected function castDataIterable( && ! $type->kind->isCursorPaginator() ) { return $value->map( - function (mixed $item) use ($dataClass, $state, &$extensions): BaseData { + function (mixed $item) use ($dataClass, $state, $extensions): BaseData { return $this->createUnvalidatedNode( $dataClass, $state->context, @@ -2099,22 +2511,37 @@ function (mixed $item) use ($dataClass, $state, &$extensions): BaseData { /** * Cast every item in one declared non-data iterable. * - * @param OperationMemo $extensions * @param list $casts + * @param array $properties + * @param null|array $castInputs */ protected function castTypedIterable( DataProperty $property, NamedType $type, mixed $value, ConstructionState $state, - array &$extensions, + CreationExtensions $extensions, array $casts, + array $properties, + ?array &$castInputs, ): mixed { + // Property casts run first, followed by the factory and configured casts for the item type. + $casts = [...$casts, ...$extensions->itemCasts($property, $type, $state->context)]; + + // Built before entering the property, so every item's cast sees the owning object's values. + if ($castInputs === null + && ($casts !== [] || $this->hasCastableType($type->iterableItemType)) + ) { + $castInputs = $this->castInputs($state, $properties); + } + + $itemInputs = $castInputs ?? []; + if ($value instanceof LazyCollection && ! $type->kind->isPaginator() && ! $type->kind->isCursorPaginator() ) { - return $value->map(function (mixed $item) use ($property, $type, $state, &$extensions, $casts): mixed { + return $value->map(function (mixed $item) use ($property, $type, $state, $extensions, $casts, $itemInputs): mixed { return $this->castIterableItem( $property, $type->iterableItemType, @@ -2122,6 +2549,7 @@ protected function castTypedIterable( $state, $extensions, $casts, + $itemInputs, ); }); } @@ -2145,6 +2573,7 @@ protected function castTypedIterable( $state, $extensions, $casts, + $itemInputs, ); } @@ -2157,16 +2586,17 @@ protected function castTypedIterable( /** * Cast one declared iterable item. * - * @param OperationMemo $extensions * @param list $casts + * @param array $castInputs */ protected function castIterableItem( DataProperty $property, Type $type, mixed $value, ConstructionState $state, - array &$extensions, + CreationExtensions $extensions, array $casts, + array $castInputs, ): mixed { if ($value === null) { return $value; @@ -2177,7 +2607,7 @@ protected function castIterableItem( continue; } - $casted = $cast->castIterableItem($property, $value, $state, $state->context); + $casted = $cast->castIterableItem($property, $value, $castInputs, $state->context); if (! $casted instanceof Uncastable) { return $casted; @@ -2185,7 +2615,8 @@ protected function castIterableItem( } if ($type->acceptsValue($value)) { - return $value; + // A float property widens an assigned int itself; a float item needs the same widening here. + return is_int($value) && $type->getSingleBuiltinType() === 'float' ? (float) $value : $value; } foreach ($type->getNamedTypes() as $namedType) { @@ -2193,10 +2624,7 @@ protected function castIterableItem( continue; } - $key = 'iterable-castable:' . $namedType->name; - /** @var CastableCast $cast */ - $cast = $extensions[$key] ??= new CastableCast($namedType->name); - $casted = $cast->cast($property, $value, $state, $state->context); + $casted = $extensions->castableCast($namedType->name)->cast($property, $value, $castInputs, $state->context); if (! $casted instanceof Uncastable) { return $casted; @@ -2206,14 +2634,10 @@ protected function castIterableItem( $dateType = $type->findAcceptedTypeForBaseType(DateTimeInterface::class); if ($dateType !== null) { - $key = 'date:' . $dateType; - /** @var DateTimeInterfaceCast $cast */ - $cast = $extensions[$key] ??= new DateTimeInterfaceCast(type: $dateType); - - return $cast->castIterableItem( + return $extensions->dateCast($dateType)->castIterableItem( $property, $value, - $state, + [], $state->context, ); } @@ -2221,14 +2645,10 @@ protected function castIterableItem( $enumType = $type->findAcceptedTypeForBaseType(BackedEnum::class); if ($enumType !== null) { - $key = 'enum:' . $enumType; - /** @var EnumCast $cast */ - $cast = $extensions[$key] ??= new EnumCast($enumType); - - return $cast->castIterableItem( + return $extensions->enumCast($enumType)->castIterableItem( $property, $value, - $state, + [], $state->context, ); } @@ -2236,14 +2656,10 @@ protected function castIterableItem( $builtin = $type->getSingleBuiltinType(); if ($builtin !== null) { - $key = 'builtin:' . $builtin; - /** @var BuiltinTypeCast $cast */ - $cast = $extensions[$key] ??= new BuiltinTypeCast($builtin); - - return $cast->castIterableItem( + return $extensions->builtinCast($builtin)->castIterableItem( $property, $value, - $state, + [], $state->context, ); } @@ -2252,138 +2668,105 @@ protected function castIterableItem( } /** - * Resolve one automatic lazy attribute for the current root operation. + * Build the declared property values a user cast receives for the current node. + * + * Values already cast for this node are used as they are, other supplied input stays raw, + * and absent properties resolve as construction would resolve them. Contextual constructor + * values are excluded. * - * @param OperationMemo $extensions + * @param array $properties values already cast for this node + * @return array */ - protected function resolveAutoLazy(DataProperty $property, array &$extensions): AutoLazy + protected function castInputs(ConstructionState $state, array $properties): array { - $attribute = $property->autoLazy; - $key = 'auto-lazy:' . spl_object_id($attribute); + $dataClass = $this->dataClasses->get($state->nodeClass() ?? $state->context->dataClass); + $inputs = []; - if (! isset($extensions[$key])) { - $extensions[$key] = $attribute->newInstance(); - } + foreach ($dataClass->properties as $property) { + $name = $property->name; + + if ($property->computed || isset($dataClass->contextualParameters[$name])) { + continue; + } - /** @var AutoLazy $autoLazy */ - $autoLazy = $extensions[$key]; + if (array_key_exists($name, $properties)) { + $inputs[$name] = $properties[$name]; - return $autoLazy; + continue; + } + + $value = $state->read($property->inputPath($state->originalKey($name))); + + if (! $value instanceof UnknownProperty) { + $inputs[$name] = $value; + } elseif ($property->hasDefaultValue) { + $inputs[$name] = $this->propertyDefaultValue($dataClass, $property); + } elseif ($property->type->isOptional + && ($state->context->useOptionalValues || ! $property->type->isNullable) + ) { + $inputs[$name] = Optional::create(); + } elseif ($property->type->isNullable) { + $inputs[$name] = null; + } + } + + return $inputs; } /** - * Get the ordered custom casts applicable to a property. - * - * @param OperationMemo $extensions - * @return list + * Determine if casting a property can reach a user cast that receives the declared values. */ - protected function propertyCasts( + protected function requiresCastInputs( DataProperty $property, CreationContext $context, - array &$extensions, - ): array { - $casts = []; - - if ($property->cast !== null) { - $key = 'attribute-cast:' . spl_object_id($property->cast); - - if (! isset($extensions[$key])) { - /** @var GetsCast $attribute */ - $attribute = $property->cast->newInstance(); - $extensions[$key] = $attribute->get(); - } - - $casts[] = $extensions[$key]; + CreationExtensions $extensions, + ): bool { + if ($extensions->propertyCasts($property, $context) !== []) { + return true; } - foreach ($context->casts as $baseType => $cast) { - if ($property->type->findAcceptedTypeForBaseType($baseType) !== null) { - $casts[] = $this->resolveCast($cast, $extensions); + foreach ($property->type->getIterableTypes() as $type) { + if (! $type->kind->isDataCollectable() && $extensions->itemCasts($property, $type, $context) !== []) { + return true; } } - foreach ($property->configuredCasts as $cast) { - $casts[] = $this->resolveCast($cast, $extensions); + foreach ($property->type->getNamedTypes() as $type) { + if ($type->isCastable + || ($type->iterableItemType !== null && $this->hasCastableType($type->iterableItemType)) + ) { + return true; + } } - return $casts; + return false; } /** - * Resolve one cast once for the current root operation. - * - * @param Cast|class-string $cast - * @param OperationMemo $extensions + * Determine if a type accepts a Castable class. */ - protected function resolveCast(Cast|string $cast, array &$extensions): Cast + protected function hasCastableType(Type $type): bool { - if ($cast instanceof Cast) { - return $cast; + foreach ($type->getNamedTypes() as $namedType) { + if ($namedType->isCastable) { + return true; + } } - $key = 'cast:' . $cast; - - /** @var Cast */ - return $extensions[$key] ??= $this->container->make($cast); + return false; } /** - * Resolve the custom normalizers for one data class. + * Determine if casts own the construction of a data object or collection property. * - * @param OperationMemo $extensions - * @return list + * Casts receive such a value before any of its objects are built. Under validation it is prepared + * and validated first, while its named factories and constructors wait until the casts decline it. */ - protected function resolveNormalizers( - DataClass $dataClass, - CreationContext $context, - array &$extensions, - ): array { - // Avoid building a memo key on the common path with no custom normalizers. - if ($context->normalizers === [] - && $this->config->normalizers === [] - && ! $dataClass->hasLifecycleMethod('normalizers') - ) { - return []; - } - - $key = 'normalizers:' . $dataClass->name; - - if (isset($extensions[$key])) { - /** @var list $normalizers */ - $normalizers = $extensions[$key]; - - return $normalizers; - } - - $normalizers = []; - - if ($dataClass->hasLifecycleMethod('normalizers')) { - $class = $dataClass->name; - /** @var list|Normalizer> $normalizers */ - $normalizers = $this->container->call($class::normalizers(...)); - } - - array_push($normalizers, ...$context->normalizers, ...$this->config->normalizers); - - foreach ($normalizers as $index => $normalizer) { - if ($normalizer instanceof Normalizer) { - continue; - } - - $normalizerKey = 'normalizer:' . $normalizer; - - if (! isset($extensions[$normalizerKey])) { - /** @var Normalizer $resolvedNormalizer */ - $resolvedNormalizer = $this->container->make($normalizer); - $extensions[$normalizerKey] = $resolvedNormalizer; - } - - /** @var Normalizer $resolvedNormalizer */ - $resolvedNormalizer = $extensions[$normalizerKey]; - $normalizers[$index] = $resolvedNormalizer; - } - - return $extensions[$key] = array_values($normalizers); + protected function isCastOwned(DataProperty $property, CreationContext $context, CreationExtensions $extensions): bool + { + return $property->type->isDataRelated + && ($property->cast !== null || $property->configuredCasts !== [] || $context->casts !== []) + && $extensions->propertyCasts($property, $context) !== []; } /** @@ -2396,18 +2779,19 @@ protected function resolveProperties( DataClass $dataClass, array $sources, CreationContext $context, + bool $compilesRules, ): array { $properties = []; foreach ($dataClass->properties as $property) { - $properties[$property->name] = $this->resolveProperty($sources, $property, $context); + $properties[$property->name] = $this->resolveProperty($sources, $property, $context, $compilesRules); } return $properties; } /** - * Resolve one property with mapped-key precedence inside each source. + * Resolve one property from the last source that supplies it, with mapped-key precedence inside each source. * * @param list $sources * @return array{array-key, mixed} @@ -2416,18 +2800,29 @@ protected function resolveProperty( array $sources, DataProperty $property, CreationContext $context, + bool $compilesRules, ): array { $mappedKey = $this->propertyInputKey($property, $context); + $optional = null; - foreach ($sources as $source) { - $match = $this->matchPropertySource($source, $property, $mappedKey); + for ($index = count($sources) - 1; $index >= 0; --$index) { + $match = $this->matchPropertySource($sources[$index], $property, $mappedKey, $compilesRules); - if ($match !== null) { - return $match; + if ($match === null) { + continue; + } + + // Optional marks absence, so it never overrides a value from an earlier source. + if ($match[1] instanceof Optional) { + $optional ??= $match; + + continue; } + + return $match; } - return [$mappedKey, UnknownProperty::create()]; + return $optional ?? [$mappedKey, UnknownProperty::create()]; } /** @@ -2441,6 +2836,7 @@ protected function resolveAutoLazySource( array $sources, array $payloads, CreationContext $context, + bool $compilesRules, ): mixed { if ($this->isAutoWhenLoaded($property)) { foreach ($payloads as $payload) { @@ -2454,8 +2850,10 @@ protected function resolveAutoLazySource( $mappedKey = $this->propertyInputKey($property, $context); - foreach ($sources as $index => $source) { - if ($this->matchPropertySource($source, $property, $mappedKey) !== null) { + for ($index = count($sources) - 1; $index >= 0; --$index) { + $match = $this->matchPropertySource($sources[$index], $property, $mappedKey, $compilesRules); + + if ($match !== null && ! $match[1] instanceof Optional) { return $payloads[$index]; } } @@ -2466,12 +2864,16 @@ protected function resolveAutoLazySource( /** * Match one property against one normalized source. * + * Validated input must use the mapped name, as its rules do. Otherwise, as upstream, the PHP + * property name is read when the mapped name is missing. + * * @return null|array{array-key, mixed} */ protected function matchPropertySource( array|Normalized $source, DataProperty $property, string|int $mappedKey, + bool $compilesRules, ): ?array { $value = SourceReader::read($source, $property->inputPath($mappedKey), $property); @@ -2479,7 +2881,7 @@ protected function matchPropertySource( return [$mappedKey, $value]; } - if ($mappedKey === $property->name) { + if ($compilesRules || $mappedKey === $property->name) { return null; } @@ -2503,7 +2905,101 @@ protected function propertyInputKey( } /** - * Merge normalized sources for a prepare-data hook. + * Pass each normalized source through the class's prepareForPipeline() method. + * + * A model source is first projected to its declared properties, as upstream does. + * + * @param list $sources + * @return list> + */ + protected function prepareSourcesForPipeline( + DataClass $dataClass, + array $sources, + CreationContext $context, + bool $compilesRules, + ): array { + $class = $dataClass->name; + $prepared = []; + + foreach ($sources as $source) { + $prepared[] = $class::prepareForPipeline( + is_array($source) ? $source : $this->projectNormalizedSource($dataClass, $source, $context, $compilesRules), + ); + } + + return $prepared; + } + + /** + * Build the prepare-data hook input from the values property resolution selects. + * + * Later array sources supply undeclared keys. Each declared property's selected value is written + * under the spelling resolution reads first, so a hook that returns its input changes nothing. + * + * @param list $sources + * @param array $resolvedProperties + * @return array + */ + protected function prepareDataInput( + DataClass $dataClass, + array $sources, + array $resolvedProperties, + CreationContext $context, + bool $compilesRules, + ): array { + if (count($sources) === 1) { + return is_array($sources[0]) + ? $sources[0] + : $this->projectNormalizedSource($dataClass, $sources[0], $context, $compilesRules); + } + + $input = $this->mergeArraySources($sources); + + foreach ($dataClass->properties as $property) { + $value = $resolvedProperties[$property->name][1]; + + if ($value instanceof UnknownProperty) { + continue; + } + + $target = &$input; + + foreach ($property->inputPath($this->propertyInputKey($property, $context)) as $segment) { + if (! is_array($target)) { + $target = []; + } + + $target = &$target[$segment]; + } + + $target = $value; + unset($target); + } + + return $input; + } + + /** + * Merge the array sources, with later sources taking precedence. + * + * @param list $sources + * @return array + */ + protected function mergeArraySources(array $sources): array + { + $input = []; + + foreach ($sources as $source) { + if (is_array($source)) { + $input = $input === [] ? $source : array_replace($input, $source); + } + } + + return $input; + } + + /** + * Merge the keys of every normalized source for unknown-field detection. * * @param list $sources * @return array @@ -2512,13 +3008,14 @@ protected function mergeSources( DataClass $dataClass, array $sources, CreationContext $context, + bool $compilesRules, ): array { $merged = []; foreach ($sources as $source) { $values = is_array($source) ? $source - : $this->projectNormalizedSource($dataClass, $source, $context); + : $this->projectNormalizedSource($dataClass, $source, $context, $compilesRules); $merged = $this->mergeMissingValues($merged, $values); } @@ -2534,11 +3031,12 @@ protected function projectNormalizedSource( DataClass $dataClass, Normalized $source, CreationContext $context, + bool $compilesRules, ): array { $values = []; foreach ($dataClass->properties as $property) { - [$key, $value] = $this->resolveProperty([$source], $property, $context); + [$key, $value] = $this->resolveProperty([$source], $property, $context, $compilesRules); if ($value instanceof UnknownProperty) { continue; @@ -2611,7 +3109,7 @@ protected function matchNamedFactory( } /** - * Resolve a named factory and normalize its remaining payloads. + * Create the object with a matching named factory, or list the payloads for ordinary creation. * * Caller keys remain meaningful through factory matching and are discarded afterwards. * @@ -2623,17 +3121,10 @@ protected function resolveFactoryPayloads( CreationContext $context, array $payloads, ): BaseData|array { - $class = $dataClass->name; $match = $this->matchNamedFactory($dataClass, $context, $payloads); if ($match !== null) { - $result = $this->invokeNamedFactory($dataClass, ...$match); - - if ($result instanceof $class) { - return $result; - } - - $payloads = [$result]; + return $this->invokeNamedObjectFactory($dataClass, ...$match); } return array_is_list($payloads) ? $payloads : array_values($payloads); @@ -2686,13 +3177,33 @@ protected function invokeNamedFactory( : $class::$methodName(...$match->arguments); } + /** + * Invoke one matched named object factory, which must return the requested object. + */ + protected function invokeNamedObjectFactory( + DataClass $dataClass, + DataMethod $method, + DataMethodMatch $match, + ): BaseData { + $data = $this->invokeNamedFactory($dataClass, $method, $match); + + if (! $data instanceof $dataClass->name) { + throw CannotCreateData::invalidNamedFactoryResult($dataClass, $method, $data); + } + + return $data; + } + /** * Resolve the concrete class selected by an abstract property's discriminator. * + * When rules compile, a missing discriminator or a null morph() result keeps the abstract class, + * so validation reports the discriminator instead of creation failing. + * * @param array $resolvedProperties * @return class-string */ - protected function resolveMorphClass(DataClass $dataClass, array $resolvedProperties): string + protected function resolveMorphClass(DataClass $dataClass, array $resolvedProperties, bool $compilesRules): string { if (! $dataClass->isAbstract) { return $dataClass->name; @@ -2716,6 +3227,10 @@ protected function resolveMorphClass(DataClass $dataClass, array $resolvedProper } if ($value instanceof UnknownProperty) { + if ($compilesRules) { + return $dataClass->name; + } + throw CannotCreateAbstractClass::morphClassWasNotResolved($dataClass->name); } @@ -2733,6 +3248,10 @@ protected function resolveMorphClass(DataClass $dataClass, array $resolvedProper $resolvedClass = $class::morph($properties); if ($resolvedClass === null) { + if ($compilesRules) { + return $class; + } + throw CannotCreateAbstractClass::morphClassWasNotResolved($class); } @@ -2772,18 +3291,30 @@ protected function propertyDefaultValue(DataClass $dataClass, DataProperty $prop /** * Determine if an automatic lazy property needs deferred Fill replay. */ - protected function requiresAutoLazyReplay(DataProperty $property): bool - { + protected function requiresAutoLazyReplay( + DataProperty $property, + CreationContext $context, + CreationExtensions $extensions, + ): bool { + // A cast-owned value is filled now or kept as given, and its deferred cast reads what was stored. + if ($this->isCastOwned($property, $context, $extensions)) { + return false; + } + if ($property->type->getDataObjectClass() !== null - || $property->type->getDataCollectableType() !== null + || $property->type->getDataCollectableTypes() !== [] ) { return true; } - $type = $property->type->getNonDataIterableType(); + // Without a value, any typed paginator a union can select may need its source replayed. + foreach ($property->type->getIterableTypes() as $type) { + if ($type->kind->isPaginator() || $type->kind->isCursorPaginator()) { + return true; + } + } - return $type !== null - && ($type->kind->isPaginator() || $type->kind->isCursorPaginator()); + return false; } /** @@ -2795,6 +3326,25 @@ protected function isAutoWhenLoaded(DataProperty $property): bool && is_a($property->autoLazy->getName(), AutoWhenLoadedLazy::class, true); } + /** + * Select and record the declared type that receives a union value. + * + * The one container type accepting the value receives it. Otherwise a value no declared type + * accepts selects the data object type, and any other value is kept. + */ + protected function selectType(DataProperty $property, mixed $value, ConstructionState $state): ?NamedType + { + $type = $property->type->acceptingContainerType($value); + + if ($type === null && ! $property->type->acceptsValue($value)) { + $type = $property->type->getDataObjectType(); + } + + $state->recordSelectedType($property->name, $type); + + return $type; + } + /** * Retain source metadata for one declared paginator property. * diff --git a/src/data/src/Support/Creation/DataInstantiator.php b/src/data/src/Support/Creation/DataInstantiator.php index 1aeb126022..9849c4a3c3 100644 --- a/src/data/src/Support/Creation/DataInstantiator.php +++ b/src/data/src/Support/Creation/DataInstantiator.php @@ -7,6 +7,7 @@ use Hypervel\Contracts\Container\Container; use Hypervel\Data\Contracts\BaseData; use Hypervel\Data\Exceptions\CannotCreateData; +use Hypervel\Data\Optional; use Hypervel\Data\Support\DataClass; class DataInstantiator @@ -37,12 +38,24 @@ public function instantiateDirect(DataClass $dataClass, array $properties): Base return new $class(...$properties); } + /** + * Resolve the contextual constructor values of a data class in its container build context. + * + * @param null|list $names the parameters to resolve, or null for every contextual parameter + * @return array + */ + public function resolveContextualParameters(DataClass $dataClass, ?array $names = null): array + { + return $this->container->resolveContextualParameters($dataClass->name, $names); + } + /** * Instantiate and assign one fully cast data node. * * @param array $properties + * @param bool $useOptionalValues whether a missing nullable Optional property outside the constructor receives Optional rather than null */ - public function instantiate(DataClass $dataClass, array $properties): BaseData + public function instantiate(DataClass $dataClass, array $properties, bool $useOptionalValues = true): BaseData { if ($dataClass->constructor !== null && ! $dataClass->constructor->isPublic()) { throw CannotCreateData::nonPublicConstructor($dataClass); @@ -57,6 +70,11 @@ public function instantiate(DataClass $dataClass, array $properties): BaseData foreach ($dataClass->constructorParameters as $parameter) { if ($parameter->contextualAttribute !== null) { + // Resolved values are passed as overrides; the container resolves any that were not. + if (array_key_exists($parameter->name, $properties)) { + $parameters[$parameter->name] = $properties[$parameter->name]; + } + $requiresContainer = true; continue; @@ -85,15 +103,30 @@ public function instantiate(DataClass $dataClass, array $properties): BaseData continue; } - if (! array_key_exists($property->name, $properties)) { - if (! $property->hasDefaultValue) { - throw CannotCreateData::propertyMissing($dataClass, $property); - } + $value = $properties[$property->name] ?? null; + + // A property promoted by an ancestor constructor belongs to the constructor chain, so input never replaces it. + if (! $property->isPromoted + && array_key_exists($property->name, $properties) + && ! $value instanceof Optional + ) { + $data->{$property->name} = $value; continue; } - $data->{$property->name} = $properties[$property->name]; + // Absent input, including Optional, keeps a value the constructor assigned. + if ($property->reflection->isInitialized($data)) { + continue; + } + + if ($property->type->isOptional && ($useOptionalValues || ! $property->type->isNullable)) { + $data->{$property->name} = Optional::create(); + } elseif ($property->type->isNullable) { + $data->{$property->name} = null; + } else { + throw CannotCreateData::propertyMissing($dataClass, $property); + } } return $data; diff --git a/src/data/src/Support/Creation/NativeScalar.php b/src/data/src/Support/Creation/NativeScalar.php new file mode 100644 index 0000000000..73f36b45e6 --- /dev/null +++ b/src/data/src/Support/Creation/NativeScalar.php @@ -0,0 +1,45 @@ + $normalizers */ - public static function resolve( - string $dataClass, - mixed $value, - array $normalizers, - ): array|Normalized { - if ($value === null) { - return []; - } - - if ($value instanceof Normalized) { - return $value; + public static function normalize(mixed $value, array $normalizers): array|Normalized|null + { + if ($value === null || $value instanceof Normalized) { + return null; } foreach ($normalizers as $normalizer) { @@ -43,6 +36,22 @@ public static function resolve( } } + return null; + } + + /** + * Resolve a value through the fixed source handling, or return null when nothing can read it. + */ + public static function resolve(mixed $value): array|Normalized|null + { + if ($value === null) { + return []; + } + + if ($value instanceof Normalized) { + return $value; + } + if (is_array($value)) { return $value; } @@ -74,6 +83,6 @@ public static function resolve( } } - throw CannotCreateData::noNormalizerFound($dataClass, $value); + return null; } } diff --git a/src/data/src/Support/Creation/ValueCaster.php b/src/data/src/Support/Creation/ValueCaster.php index e4e2b33452..c1279f34d9 100644 --- a/src/data/src/Support/Creation/ValueCaster.php +++ b/src/data/src/Support/Creation/ValueCaster.php @@ -29,10 +29,10 @@ public static function castBuiltin(string $type, mixed $value): mixed { return match ($type) { 'bool' => self::castBoolean($value), - 'int' => (int) $value, - 'float' => (float) $value, + 'int' => NativeScalar::int($value), + 'float' => NativeScalar::float($value), 'array' => (array) $value, - 'string' => (string) $value, + 'string' => NativeScalar::string($value), }; } @@ -124,14 +124,10 @@ public static function castDate( */ protected static function castBoolean(mixed $value): bool { - if (! is_string($value)) { - return (bool) $value; - } - - return match (strtolower($value)) { + return match (is_string($value) ? strtolower($value) : null) { 'true' => true, 'false' => false, - default => (bool) $value, + default => NativeScalar::bool($value), }; } diff --git a/src/data/src/Support/DataClass.php b/src/data/src/Support/DataClass.php index a83f05d776..e2ae5b67be 100644 --- a/src/data/src/Support/DataClass.php +++ b/src/data/src/Support/DataClass.php @@ -28,6 +28,7 @@ * @param array $methods * @param list $constructorParameters * @param array $contextualParameters + * @param list $constructorInputs non-promoted constructor parameters without a public data property, which receive raw input * @param array $lifecycleMethods * @param array> $dataIterablePropertyAnnotations * @param array $outputMappedProperties @@ -39,6 +40,7 @@ public function __construct( public ?ReflectionMethod $constructor, public array $constructorParameters, public array $contextualParameters, + public array $constructorInputs, public bool $isReadonly, public bool $isAbstract, public bool $isFinal, diff --git a/src/data/src/Support/DataClassRepository.php b/src/data/src/Support/DataClassRepository.php index 54d609ab43..06bf9db3a8 100644 --- a/src/data/src/Support/DataClassRepository.php +++ b/src/data/src/Support/DataClassRepository.php @@ -90,19 +90,8 @@ protected function resolveDynamicRuleGraph(string $class, array &$visited): bool return $this->dynamicRuleGraphs[$class] = true; } - $contextualProperties = []; - - foreach ($dataClass->constructorParameters as $parameter) { - if ($parameter->isPromoted && $parameter->contextualAttribute !== null) { - $contextualProperties[$parameter->name] = true; - } - } - foreach ($dataClass->properties as $property) { - if ($property->computed - || ! $property->validate - || isset($contextualProperties[$property->name]) - ) { + if ($property->computed || ! $property->validate) { continue; } @@ -114,8 +103,11 @@ protected function resolveDynamicRuleGraph(string $class, array &$visited): bool $nestedClasses[$dataObjectTypes[0]->dataClass] = true; } - if (count($dataCollectableTypes) === 1 && $dataCollectableTypes[0]->dataClass !== null) { - $nestedClasses[$dataCollectableTypes[0]->dataClass] = true; + // Each collection type of a container union can be selected by its input value. + foreach ($dataCollectableTypes as $dataCollectableType) { + if ($dataCollectableType->dataClass !== null) { + $nestedClasses[$dataCollectableType->dataClass] = true; + } } foreach (array_keys($nestedClasses) as $nestedClass) { diff --git a/src/data/src/Support/DataConfig.php b/src/data/src/Support/DataConfig.php index e18361150d..e1b0e4c6b3 100644 --- a/src/data/src/Support/DataConfig.php +++ b/src/data/src/Support/DataConfig.php @@ -9,6 +9,7 @@ use Hypervel\Data\Contracts\BaseData; use Hypervel\Data\Mappers\NameMapper; use Hypervel\Data\Normalizers\Normalizer; +use Hypervel\Data\RuleInferrers\RuleInferrer; use Hypervel\Data\Support\Creation\ValidationStrategy; use Hypervel\Data\Transformers\Transformer; use InvalidArgumentException; @@ -60,6 +61,13 @@ class DataConfig */ public readonly array $transformers; + /** + * The configured rule inferrers, run after the fixed rule inference. + * + * @var list> + */ + public readonly array $ruleInferrers; + /** * The configured global normalizers. * @@ -77,6 +85,21 @@ class DataConfig */ public readonly ?int $maxTransformationDepth; + /** + * Indicates if nested partials targeting unknown properties should be ignored instead of rejected. + */ + public readonly bool $ignoreInvalidPartials; + + /** + * Indicates if reaching the maximum transformation depth throws instead of returning an empty array. + */ + public readonly bool $throwWhenMaxTransformationDepthReached; + + /** + * Indicates if supplied computed property values should be ignored instead of rejected. + */ + public readonly bool $ignoreComputedPropertyInput; + /** @var array> */ protected array $morphMap = []; @@ -99,6 +122,11 @@ public function __construct(Repository $config) Transformer::class, 'data.transformers', ); + $this->ruleInferrers = self::extensionList( + $config->array('data.rule_inferrers'), + RuleInferrer::class, + 'data.rule_inferrers', + ); $this->normalizers = self::extensionList( $config->array('data.normalizers'), Normalizer::class, @@ -109,10 +137,17 @@ public function __construct(Repository $config) $config, 'data.max_transformation_depth', ); + $this->ignoreInvalidPartials = $config->boolean('data.ignore_invalid_partials'); + $this->throwWhenMaxTransformationDepthReached = $config->boolean( + 'data.throw_when_max_transformation_depth_reached', + ); + $this->ignoreComputedPropertyInput = $config->boolean( + 'data.features.ignore_exception_when_trying_to_set_computed_property_value', + ); } /** - * Register the enforced data morph map. + * Register aliases that abstract data casts store instead of class names. * * Boot-only. The aliases persist on the worker-lifetime configuration and * affect every subsequent data cast in the worker. diff --git a/src/data/src/Support/DataProperty.php b/src/data/src/Support/DataProperty.php index a8d1b48b5e..b803c57abd 100644 --- a/src/data/src/Support/DataProperty.php +++ b/src/data/src/Support/DataProperty.php @@ -32,6 +32,7 @@ class DataProperty * @param null|non-empty-list $inputMappedPath * @param null|'array'|'bool'|'float'|'int'|'string'|class-string $constructionTarget * @param list> $configuredCasts + * @param array>> $configuredItemCasts configured item casts keyed by plain iterable type name * @param list> $configuredTransformers */ public function __construct( @@ -58,6 +59,7 @@ public function __construct( public readonly ?array $inputMappedPath, public readonly string|int|null $outputMappedName, public readonly array $configuredCasts, + public readonly array $configuredItemCasts, public readonly array $configuredTransformers, public readonly DataAttributesCollection $attributes, public readonly ReflectionProperty $reflection, diff --git a/src/data/src/Support/DataPropertyType.php b/src/data/src/Support/DataPropertyType.php index c227ed2242..41761bf663 100644 --- a/src/data/src/Support/DataPropertyType.php +++ b/src/data/src/Support/DataPropertyType.php @@ -6,6 +6,7 @@ use Hypervel\Data\Contracts\BaseData; use Hypervel\Data\Lazy; +use Hypervel\Data\Optional; use Hypervel\Data\Support\Types\NamedType; use Hypervel\Data\Support\Types\Type; @@ -38,6 +39,36 @@ class DataPropertyType extends DataType protected readonly ?NamedType $nonDataIterableType; + /** + * The declared data collection and plain iterable container types. + * + * @var list + */ + protected readonly array $containerTypes; + + /** + * Whether more than one declared type can hold a supplied value; null, Optional, and Lazy are not counted. + */ + public readonly bool $isUnion; + + /** + * Whether more than one declared data collection or plain iterable type can hold an iterable value. + */ + public readonly bool $hasContainerUnion; + + /** + * Whether a data object or data collection type is declared. + */ + public readonly bool $isDataRelated; + + /** + * Whether creation records the declared type a value selects. + * + * A data value is replaced by its normalized input, which another type of the union could then + * appear to accept, so casting and validation follow the type selected from the raw value. + */ + public readonly bool $recordsSelectedType; + /** * Create a new data property type. * @@ -56,6 +87,8 @@ public function __construct( $dataCollectableTypes = []; $iterableTypes = []; $nonDataIterableTypes = []; + $containerTypes = []; + $valueTypes = 0; foreach ($this->getNamedTypes() as $namedType) { if ($namedType->kind->isDataObject()) { @@ -73,6 +106,18 @@ public function __construct( $nonDataIterableTypes[] = $namedType; } } + + if ($namedType->kind->isDataCollectable() || $namedType->kind->isNonDataIterable()) { + $containerTypes[] = $namedType; + } + + $holdsValues = $namedType->builtIn + ? $namedType->name !== 'null' + : ! is_a($namedType->name, Optional::class, true) && ! is_a($namedType->name, Lazy::class, true); + + if ($holdsValues) { + ++$valueTypes; + } } $this->dataObjectTypes = $dataObjectTypes; @@ -81,6 +126,11 @@ public function __construct( $this->dataCollectableType = count($dataCollectableTypes) === 1 ? $dataCollectableTypes[0] : null; $this->iterableTypes = $iterableTypes; $this->nonDataIterableType = count($nonDataIterableTypes) === 1 ? $nonDataIterableTypes[0] : null; + $this->containerTypes = $containerTypes; + $this->isUnion = $valueTypes > 1; + $this->hasContainerUnion = count($containerTypes) > 1; + $this->isDataRelated = $dataObjectTypes !== [] || $dataCollectableTypes !== []; + $this->recordsSelectedType = $this->hasContainerUnion || ($this->isUnion && $dataObjectTypes !== []); } /** @@ -146,4 +196,38 @@ public function getNonDataIterableType(): ?NamedType { return $this->nonDataIterableType; } + + /** + * Get the declared data collection and plain iterable container types. + * + * @return list + */ + public function getContainerTypes(): array + { + return $this->containerTypes; + } + + /** + * Get the one declared container type whose container accepts a value. + * + * Null means no container type accepts the value, or more than one does. + */ + public function acceptingContainerType(mixed $value): ?NamedType + { + $accepting = null; + + foreach ($this->containerTypes as $type) { + if (! $type->acceptsValue($value)) { + continue; + } + + if ($accepting !== null) { + return null; + } + + $accepting = $type; + } + + return $accepting; + } } diff --git a/src/data/src/Support/Factories/DataClassFactory.php b/src/data/src/Support/Factories/DataClassFactory.php index 9afcb13259..1e93585159 100644 --- a/src/data/src/Support/Factories/DataClassFactory.php +++ b/src/data/src/Support/Factories/DataClassFactory.php @@ -81,8 +81,7 @@ public function build(ReflectionClass $reflectionClass): DataClass $constructorParameters = $this->resolveConstructorParameters($reflectionClass, $constructor); $contextualParameters = $this->resolveContextualParameters($constructorParameters); $reflectionProperties = $this->resolveReflectionProperties($reflectionClass); - - $this->validateConstructorParameters($name, $constructorParameters, $reflectionProperties); + $constructorInputs = $this->resolveConstructorInputs($constructorParameters, $reflectionProperties); $classInputNameMapper = $this->nameMapperResolver->resolveInput( $attributes, @@ -132,6 +131,7 @@ public function build(ReflectionClass $reflectionClass): DataClass constructor: $constructor, constructorParameters: array_values($constructorParameters), contextualParameters: $contextualParameters, + constructorInputs: $constructorInputs, isReadonly: $reflectionClass->isReadOnly(), isAbstract: $reflectionClass->isAbstract(), isFinal: $reflectionClass->isFinal(), @@ -155,6 +155,7 @@ public function build(ReflectionClass $reflectionClass): DataClass creationRecipe: $this->resolveCreationRecipe( $reflectionClass, $contextualParameters, + $constructorInputs, $properties, $lifecycleMethods, $propertyMorphable, @@ -162,7 +163,9 @@ public function build(ReflectionClass $reflectionClass): DataClass directConstructorInstantiation: $this->supportsDirectConstructorInstantiation( $reflectionClass, $constructor, + $constructorParameters, $contextualParameters, + $constructorInputs, $properties, ), attributes: $attributes, @@ -234,28 +237,29 @@ protected function resolveReflectionProperties(ReflectionClass $reflectionClass) } /** - * Validate that constructor inputs have one supported ownership form. + * Get the constructor parameters that receive raw input without a public data property. + * + * These are non-promoted parameters without a matching property. A non-public promoted parameter is the + * object's own state, so input never reaches it, as in Spatie. * - * @param class-string $class * @param array $parameters * @param array $properties + * @return list */ - protected function validateConstructorParameters( - string $class, - array $parameters, - array $properties, - ): void { - foreach ($parameters as $parameter) { - if ($parameter->isPromoted && ! isset($properties[$parameter->name])) { - throw InvalidDataDeclaration::nonPublicPromotedProperty($class, $parameter); - } + protected function resolveConstructorInputs(array $parameters, array $properties): array + { + $inputs = []; - if (! $parameter->isPromoted - && $parameter->contextualAttribute === null - && ! isset($properties[$parameter->name])) { - throw InvalidDataDeclaration::missingDataProperty($class, $parameter); + foreach ($parameters as $parameter) { + if ($parameter->contextualAttribute === null + && ! $parameter->isPromoted + && ! isset($properties[$parameter->name]) + ) { + $inputs[] = $parameter->name; } } + + return $inputs; } /** @@ -334,7 +338,12 @@ classAutoLazy: $classAutoLazy, throw InvalidDataDeclaration::writeOnlyProperty($class, $property); } - if ($property->isReadonly && ! $property->isConstructorParameter && ! $property->computed) { + // A readonly property promoted by an ancestor constructor is assigned by that constructor chain. + if ($property->isReadonly + && ! $property->isConstructorParameter + && ! $property->isPromoted + && ! $property->computed + ) { throw InvalidDataDeclaration::unassignableReadonlyProperty($class, $property); } @@ -475,6 +484,7 @@ protected function resolveLifecycleMethods(ReflectionClass $reflectionClass): ar 'withValidator', 'after', 'normalizers', + 'prepareForPipeline', 'stopOnFirstFailure', 'redirect', 'redirectRoute', @@ -557,12 +567,14 @@ protected function validateMappings(string $class, array $properties): array * * @param ReflectionClass $reflectionClass * @param array $contextualParameters + * @param list $constructorInputs * @param array $properties * @param array $lifecycleMethods */ protected function resolveCreationRecipe( ReflectionClass $reflectionClass, array $contextualParameters, + array $constructorInputs, array $properties, array $lifecycleMethods, bool $propertyMorphable, @@ -570,11 +582,12 @@ protected function resolveCreationRecipe( if ($reflectionClass->isAbstract() || $propertyMorphable || isset($lifecycleMethods['normalizers']) + || isset($lifecycleMethods['prepareForPipeline']) || $this->config->normalizers !== []) { return null; } - if ($contextualParameters !== []) { + if ($contextualParameters !== [] || $constructorInputs !== []) { return null; } @@ -596,21 +609,34 @@ protected function resolveCreationRecipe( * Determine if resolved recipe values can be spread directly into the constructor. * * @param ReflectionClass $reflectionClass + * @param array $constructorParameters * @param array $contextualParameters + * @param list $constructorInputs * @param array $properties */ protected function supportsDirectConstructorInstantiation( ReflectionClass $reflectionClass, ?ReflectionMethod $constructor, + array $constructorParameters, array $contextualParameters, + array $constructorInputs, array $properties, ): bool { if ($reflectionClass->isAbstract() || ($constructor !== null && (! $constructor->isPublic() || $constructor->isVariadic())) - || $contextualParameters !== []) { + || $contextualParameters !== [] + || $constructorInputs !== []) { return false; } + // A required parameter without a public data property, such as a non-public promoted one, receives no value, + // so construction must report it as missing. + foreach ($constructorParameters as $name => $parameter) { + if (! $parameter->hasDefaultValue && ! isset($properties[$name])) { + return false; + } + } + foreach ($properties as $property) { if (! $property->computed && ! $property->isConstructorParameter) { return false; diff --git a/src/data/src/Support/Factories/DataPropertyFactory.php b/src/data/src/Support/Factories/DataPropertyFactory.php index 6b07a9a41d..49dd16d1cb 100644 --- a/src/data/src/Support/Factories/DataPropertyFactory.php +++ b/src/data/src/Support/Factories/DataPropertyFactory.php @@ -26,6 +26,7 @@ use Hypervel\Data\Support\DataProperty; use Hypervel\Data\Support\DataPropertyType; use Hypervel\Data\Support\NameMapperResolver; +use Hypervel\Data\Support\Types\Type; use Hypervel\Database\Eloquent\Collection as EloquentCollection; use Hypervel\Database\Eloquent\Model; use PropertyHookType; @@ -101,6 +102,20 @@ public function build( $isVirtual = $reflectionProperty->isVirtual(); $computed = $attributes->has(Computed::class) || $isVirtual; $configuredCasts = $this->applicableExtensions($type, $this->config->casts); + $configuredItemCasts = []; + + foreach ($type->getNamedTypes() as $namedType) { + if ($namedType->iterableItemType === null || $namedType->kind->isDataCollectable()) { + continue; + } + + $itemCasts = $this->applicableExtensions($namedType->iterableItemType, $this->config->casts); + + if ($itemCasts !== []) { + $configuredItemCasts[$namedType->name] = $itemCasts; + } + } + $configuredTransformers = $this->applicableExtensions($type, $this->config->transformers); [$constructionOperation, $constructionTarget] = $this->resolveConstructionOperation($type, $computed); @@ -112,7 +127,6 @@ className: $reflectionProperty->class, constructionTarget: $constructionTarget, transformationOperation: $this->resolveTransformationOperation($type), validate: ! $computed - && $constructorParameter?->contextualAttribute === null && ! $attributes->has(AutoWhenLoadedLazy::class) && ! $attributes->has(WithoutValidation::class), computed: $computed, @@ -134,6 +148,7 @@ className: $reflectionProperty->class, : (is_int($inputMappedName) ? [$inputMappedName] : explode('.', $inputMappedName)), outputMappedName: $outputMappedName, configuredCasts: $configuredCasts, + configuredItemCasts: $configuredItemCasts, configuredTransformers: $configuredTransformers, attributes: $attributes, reflection: $reflectionProperty, @@ -274,14 +289,14 @@ protected function acceptedTypes(DataPropertyType $type, string $baseType): arra } /** - * Select configured extensions that apply to a property type. + * Select configured extensions that apply to a property or iterable item type. * * @template TExtension of object * * @param array> $extensions * @return list> */ - protected function applicableExtensions(DataPropertyType $type, array $extensions): array + protected function applicableExtensions(DataPropertyType|Type $type, array $extensions): array { $applicable = []; diff --git a/src/data/src/Support/Factories/DataTypeFactory.php b/src/data/src/Support/Factories/DataTypeFactory.php index c81d84cac2..8e66fd64e9 100644 --- a/src/data/src/Support/Factories/DataTypeFactory.php +++ b/src/data/src/Support/Factories/DataTypeFactory.php @@ -11,11 +11,11 @@ use Hypervel\Data\CursorPaginatedDataCollection; use Hypervel\Data\DataCollection; use Hypervel\Data\Enums\DataTypeKind; -use Hypervel\Data\Exceptions\CannotFindDataClass; use Hypervel\Data\Lazy; use Hypervel\Data\Optional; use Hypervel\Data\PaginatedDataCollection; use Hypervel\Data\Support\Annotations\DataIterableAnnotation; +use Hypervel\Data\Support\Annotations\DataIterableAnnotationReader; use Hypervel\Data\Support\DataAttributesCollection; use Hypervel\Data\Support\DataPropertyType; use Hypervel\Data\Support\DataType; @@ -55,6 +55,7 @@ class DataTypeFactory */ public function __construct( protected readonly PhpDocTypeNameResolver $typeNameResolver, + protected readonly DataIterableAnnotationReader $annotationReader, ) { } @@ -79,7 +80,6 @@ public function buildProperty( $reflectionType, $class, $declaringClass, - $typeable, $iterableAnnotations, $collectionOf instanceof DataCollectionOf ? $collectionOf->class : null, true, @@ -110,7 +110,6 @@ public function build( $reflectionType, $class, $this->declaringClass($typeable, $class), - $typeable, ); return new DataType( @@ -120,30 +119,6 @@ public function build( ); } - /** - * Build a data type from a declared type name. - * - * @param class-string|ReflectionClass $class - */ - public function buildFromString( - string $type, - ReflectionClass|string $class, - bool $isBuiltIn, - bool $isNullable = false, - ): DataType { - $class = $this->reflectionClass($class); - $namedType = $this->buildNamedType( - $this->resolveNativeName($type, $class, $class), - $isBuiltIn, - ); - - return new DataType( - type: $namedType, - isNullable: $isNullable, - isMixed: $namedType->name === 'mixed', - ); - } - /** * Build a reflected type graph. * @@ -155,7 +130,6 @@ protected function buildNativeType( ?ReflectionType $reflectionType, ReflectionClass $targetClass, ReflectionClass $declaringClass, - ReflectionMethod|ReflectionProperty|ReflectionParameter|string $typeable, array $iterableAnnotations = [], ?string $collectionOf = null, bool $forProperty = false, @@ -182,7 +156,9 @@ protected function buildNativeType( $kind, $targetClass, $iterableAnnotations, - )) { + ) ?? ($forProperty && ! $reflectionType->isBuiltin() + ? $this->annotationReader->getForCollectionClass(ClassMetadataCache::reflectClass($name)) + : null)) { $annotationClass = ClassMetadataCache::reflectClass($annotation->declaringClass); $itemType = $this->buildPhpDocType( $annotation->itemType, @@ -197,8 +173,9 @@ protected function buildNativeType( $itemType, ); + // Without an item class, the property holds a finished collection that is transformed by value, as in Spatie. if ($forProperty && $this->requiresDataItemType($type->kind) && $type->dataClass === null) { - throw CannotFindDataClass::forTypeable($typeable); + return new NamedType($name, false, DataTypeKind::Default); } return $type; @@ -212,7 +189,6 @@ protected function buildNativeType( $subType, $targetClass, $declaringClass, - $typeable, $iterableAnnotations, $collectionOf, $forProperty, @@ -372,6 +348,8 @@ protected function matchingAnnotation( array $annotations, ): ?DataIterableAnnotation { $fallback = null; + $shorthandFallback = null; + $itemFallbacks = []; foreach ($annotations as $annotation) { $container = $this->resolvePhpDocName( @@ -391,10 +369,42 @@ protected function matchingAnnotation( ) ) { $fallback = $annotation; + + continue; + } + + // Item shorthands such as `Item[]` or `array` describe any container, as in upstream. + if ($container === 'array' || $container === 'iterable') { + $shorthandFallback ??= $annotation; + + continue; + } + + // Another collection's annotation, such as `DataCollection` on an array, also gives the items, as in + // upstream, but only when no other collection annotation gives a different item type. + $containerKind = $this->kindFor($container); + + if ($containerKind->isNonDataIterable() || $containerKind->isDataCollectable()) { + $itemType = $this->buildPhpDocType( + $annotation->itemType, + $targetClass, + ClassMetadataCache::reflectClass($annotation->declaringClass), + ); + + // Resolved types compare by value, so an imported and a qualified name for one item agree. + foreach ($itemFallbacks as [$fallbackType]) { + if ($fallbackType == $itemType) { + continue 2; + } + } + + $itemFallbacks[] = [$itemType, $annotation]; } } - return $fallback; + return $fallback + ?? $shorthandFallback + ?? (count($itemFallbacks) === 1 ? $itemFallbacks[0][1] : null); } /** diff --git a/src/data/src/Support/Partials/PartialsDefinition.php b/src/data/src/Support/Partials/PartialsDefinition.php index a0a9776f7e..dd463b7a90 100644 --- a/src/data/src/Support/Partials/PartialsDefinition.php +++ b/src/data/src/Support/Partials/PartialsDefinition.php @@ -32,6 +32,19 @@ public function isEmpty(): bool && $this->except === []; } + /** + * Determine whether a resolved partial set contains any definitions. + * + * @param array{include: list, exclude: list, only: list, except: list} $definitions + */ + public static function hasResolved(array $definitions): bool + { + return $definitions['include'] !== [] + || $definitions['exclude'] !== [] + || $definitions['only'] !== [] + || $definitions['except'] !== []; + } + /** * Add a partial definition. */ @@ -79,15 +92,27 @@ public function addDefaults(string $type, array $definitions): void } /** - * Add definitions resolved by an enclosing data object. + * Add definitions resolved by an enclosing data object or collection. * * @param array{include: list, exclude: list, only: list, except: list} $definitions */ public function addResolved(array $definitions): void { foreach ($definitions as $type => $resolved) { + $existing = &$this->definitions($type); + foreach ($resolved as $definition) { - $this->add($type, $definition->path, $definition->permanent); + // Repeated reads and all() calls copy the same selections, so a held copy isn't added again. + foreach ($existing as $current) { + if ($current->condition === null + && $current->path === $definition->path + && $current->permanent === $definition->permanent + ) { + continue 2; + } + } + + $existing[] = new PartialDefinition($definition->path, $definition->permanent); } } } diff --git a/src/data/src/Support/Transformation/DataTransformer.php b/src/data/src/Support/Transformation/DataTransformer.php index 7245dd73ca..115d32f74a 100644 --- a/src/data/src/Support/Transformation/DataTransformer.php +++ b/src/data/src/Support/Transformation/DataTransformer.php @@ -21,6 +21,7 @@ use Hypervel\Data\CursorPaginatedDataCollection; use Hypervel\Data\DataCollection; use Hypervel\Data\Enums\DataPropertyOperation; +use Hypervel\Data\Exceptions\CannotPerformPartialOnDataField; use Hypervel\Data\Exceptions\CannotTransformData; use Hypervel\Data\Exceptions\MaxTransformationDepthReached; use Hypervel\Data\Lazy; @@ -31,12 +32,15 @@ use Hypervel\Data\Support\DataConfig; use Hypervel\Data\Support\DataProperty; use Hypervel\Data\Support\Lazy\DefaultLazy; -use Hypervel\Data\Support\Partials\PartialDefinition; +use Hypervel\Data\Support\Partials\PartialsDefinition; +use Hypervel\Data\Support\Types\NamedType; use Hypervel\Data\Support\Types\Type; use Hypervel\Data\Support\Wrapping\WrapExecutionType; use Hypervel\Data\Transformers\Transformer; use Hypervel\Pagination\AbstractCursorPaginator; use Hypervel\Pagination\AbstractPaginator; +use Hypervel\Pagination\LengthAwarePaginator; +use Hypervel\Support\Arr; use Hypervel\Support\Collection; class DataTransformer @@ -62,10 +66,12 @@ public function __construct( : new DateTimeZone($config->dateTimezone); $this->defaultContext = new TransformationContext( maxDepth: $config->maxTransformationDepth, + throwWhenMaxDepthReached: $config->throwWhenMaxTransformationDepthReached, ); $this->allContext = new TransformationContext( transformValues: false, maxDepth: $config->maxTransformationDepth, + throwWhenMaxDepthReached: $config->throwWhenMaxTransformationDepthReached, ); $this->persistenceContext = TransformationContextFactory::persistenceContext( $config->maxTransformationDepth, @@ -126,6 +132,8 @@ public function transform( /** * Transform the root payload for Hypervel's resource response pipeline. + * + * The resource wraps the root and adds its additional and pagination data, so the root leaves them out. */ public function transformForResourceResponse( (BaseData&TransformableData)|(BaseDataCollectable&TransformableData) $data, @@ -139,10 +147,10 @@ public function transformForResourceResponse( $data, $context, $extensions, - includePaginationData: false, + resourceRoot: true, rootItems: $rootItems, ) - : $this->transformData($data, $context, $extensions, includeAdditionalData: false); + : $this->transformData($data, $context, $extensions, resourceRoot: true); } /** @@ -155,10 +163,10 @@ protected function transformData( BaseData&TransformableData $data, TransformationContext $context, array &$extensions, - bool $includeAdditionalData = true, + bool $resourceRoot = false, ): array { - if ($context->maxDepth !== null && $context->depth >= $context->maxDepth) { - throw MaxTransformationDepthReached::create($context->maxDepth); + if ($this->reachedMaxDepth($context)) { + return []; } $dataClass = $this->dataClasses->get($data::class); @@ -173,7 +181,7 @@ protected function transformData( $data, $context, $this->transformBulkCopy($data, $dataClass), - $includeAdditionalData, + $resourceRoot, ); } } elseif (($recipe = $dataClass->transformationRecipe) !== null) { @@ -186,11 +194,20 @@ protected function transformData( $data, $context, $this->transformUsingRecipe($data, $recipe, $context, $extensions), - $includeAdditionalData, + $resourceRoot, ); } } + if (! $this->config->ignoreInvalidPartials + && (($context->include->nestedProperties ?? []) !== [] + || ($context->exclude->nestedProperties ?? []) !== [] + || ($context->only->nestedProperties ?? []) !== [] + || ($context->except->nestedProperties ?? []) !== []) + ) { + $this->ensureNestedPartialsExist($dataClass, $context); + } + // Raw storage keeps excluded property hooks from running as a side effect. $values = (array) $data; $transformed = []; @@ -214,10 +231,6 @@ protected function transformData( continue; } - if ($value instanceof Optional) { - continue; - } - if ($value instanceof Lazy) { if (! $this->includesLazy($value, $property, $context)) { continue; @@ -226,6 +239,10 @@ protected function transformData( $value = $value->resolve(); } + if ($value instanceof Optional) { + continue; + } + $value = $this->transformPropertyValue( $property, $value, @@ -244,7 +261,7 @@ protected function transformData( $data, $context, $transformed, - $includeAdditionalData, + $resourceRoot, ); } @@ -257,11 +274,11 @@ protected function transformCollectable( BaseDataCollectable&TransformableData $data, TransformationContext $context, array &$extensions, - bool $includePaginationData = true, + bool $resourceRoot = false, ?Collection $rootItems = null, ): array { - if ($context->maxDepth !== null && $context->depth >= $context->maxDepth) { - throw MaxTransformationDepthReached::create($context->maxDepth); + if ($this->reachedMaxDepth($context)) { + return []; } $transformed = []; @@ -288,9 +305,11 @@ protected function transformCollectable( ); } - if ($includePaginationData - && ($data instanceof PaginatedDataCollection - || $data instanceof CursorPaginatedDataCollection) + if ($resourceRoot) { + return $transformed; + } + + if (($data instanceof PaginatedDataCollection || $data instanceof CursorPaginatedDataCollection) && $context->transformValues ) { return $this->transformPaginatorCollectable($data, $transformed); @@ -301,6 +320,22 @@ protected function transformCollectable( : $transformed; } + /** + * Determine whether a data value lies at the maximum depth, throwing when configured to. + */ + protected function reachedMaxDepth(TransformationContext $context): bool + { + if ($context->maxDepth === null || $context->depth < $context->maxDepth) { + return false; + } + + if ($context->throwWhenMaxDepthReached) { + throw MaxTransformationDepthReached::create($context->maxDepth); + } + + return true; + } + /** * Transform a reached data value within the current root operation. * @@ -324,7 +359,7 @@ protected function transformNested( } /** - * Transform a paginator while retaining its native metadata. + * Transform a paginated data collection with its pagination links and metadata. * * @param array $items * @return array @@ -333,21 +368,15 @@ protected function transformPaginatorCollectable( PaginatedDataCollection|CursorPaginatedDataCollection $data, array $items, ): array { - $transformed = $this->transformPaginator($data->items(), $items); - $wrapKey = $data->getWrap()->getKey($this->config->wrap) ?? 'data'; - - if ($wrapKey === 'data') { - return $transformed; - } - - $items = $transformed['data']; - unset($transformed['data']); - - return [$wrapKey => $items, ...$transformed]; + return $this->transformPaginator( + $data->items(), + $items, + $data->getWrap()->getKey($this->config->wrap), + ); } /** - * Serialize a paginator clone with replacement items while retaining native metadata. + * Transform a paginator into its wrapped items, links, and metadata. * * @param array $items * @return array @@ -355,10 +384,66 @@ protected function transformPaginatorCollectable( protected function transformPaginator( AbstractPaginator|AbstractCursorPaginator $paginator, array $items, + ?string $wrapKey, + ): array { + return [ + $wrapKey ?? 'data' => $items, + ...$this->paginationInformation($paginator, $items), + ]; + } + + /** + * Get the links and metadata describing a paginator's current page. + * + * @param array $items the transformed items of the current page + * @return array{links: array, meta: array} + */ + public function paginationInformation( + AbstractPaginator|AbstractCursorPaginator $paginator, + array $items, ): array { - return (clone $paginator) + if ($paginator instanceof LengthAwarePaginator) { + return [ + 'links' => $paginator->linkCollection()->toArray(), + 'meta' => [ + 'current_page' => $paginator->currentPage(), + 'first_page_url' => $paginator->url(1), + 'from' => $paginator->firstItem(), + 'last_page' => $paginator->lastPage(), + 'last_page_url' => $paginator->url($paginator->lastPage()), + 'next_page_url' => $paginator->nextPageUrl(), + 'path' => $paginator->path(), + 'per_page' => $paginator->perPage(), + 'prev_page_url' => $paginator->previousPageUrl(), + 'to' => $paginator->lastItem(), + 'total' => $paginator->total(), + ], + ]; + } + + if ($paginator instanceof AbstractCursorPaginator) { + return [ + 'links' => [], + 'meta' => [ + 'path' => $paginator->path(), + 'per_page' => $paginator->perPage(), + 'next_cursor' => $paginator->nextCursor()?->encode(), + 'next_page_url' => $paginator->nextPageUrl(), + 'prev_cursor' => $paginator->previousCursor()?->encode(), + 'prev_page_url' => $paginator->previousPageUrl(), + ], + ]; + } + + // Other paginators describe their own page; the transformed items keep serialization from repeating. + $paginated = (clone $paginator) ->setCollection(new Collection($items)) ->toArray(); + + return [ + 'links' => $paginated['links'] ?? [], + 'meta' => Arr::except($paginated, ['data', 'links']), + ]; } /** @@ -368,9 +453,12 @@ protected function transformPaginator( */ protected function collectableItems(BaseDataCollectable $data): iterable { - return $data instanceof DataCollection - ? $data->toCollection() - : $data; + return match (true) { + $data instanceof DataCollection => $data->toCollection(), + $data instanceof PaginatedDataCollection, + $data instanceof CursorPaginatedDataCollection => $data->items()->getCollection(), + default => $data, + }; } /** @@ -380,14 +468,17 @@ protected function finalizeTransformation( BaseData $data, TransformationContext $context, array $transformed, - bool $includeAdditionalData, + bool $resourceRoot, ): array { + if ($resourceRoot) { + return $transformed; + } + if ($data instanceof WrappableData && $context->wrapExecutionType->shouldExecute()) { $transformed = $data->getWrap()->wrap($transformed, $this->config->wrap); } - if (! $includeAdditionalData - || $context->constructable + if ($context->constructable || ! $data instanceof AppendableData ) { return $transformed; @@ -545,24 +636,38 @@ protected function transformPropertyValue( $iterableType = $this->iterableTypeForValue($property, $value); - if ($iterableType !== null) { + if ($iterableType?->iterableItemType !== null) { if (! $context->transformValues) { $this->propagateIterablePartials($value, $context, $property->name); return $value; } + $iterableContext = $context->child($property->name); + + // Data items in any container count as one level, as they do in a DataCollection. + if ($iterableType->kind->isDataCollectable() && $this->reachedMaxDepth($iterableContext)) { + return []; + } + $transformed = $this->transformIterable( $property, $value, - $iterableType, - $context->child($property->name), + $iterableType->iterableItemType, + $iterableContext, $extensions, ); - return $value instanceof AbstractPaginator || $value instanceof AbstractCursorPaginator - ? $this->transformPaginator($value, $transformed) - : $transformed; + if ($value instanceof AbstractPaginator || $value instanceof AbstractCursorPaginator) { + return $this->transformPaginator($value, $transformed, $this->config->wrap); + } + + // Once wrapping applies, as in a response, a nested data iterable takes the global wrap key, as in Spatie. + return $iterableType->kind->isDataCollectable() + && $context->wrapExecutionType !== WrapExecutionType::Disabled + && $this->config->wrap !== null + ? [$this->config->wrap => $transformed] + : $transformed; } if (is_array($value)) { @@ -600,6 +705,33 @@ protected function resolveWrapExecutionType( : WrapExecutionType::TemporarilyDisabled; } + /** + * Ensure every nested partial path starts with a visible property of the data class. + * + * Flat selections of unknown names select nothing, as upstream allows. + */ + protected function ensureNestedPartialsExist(DataClass $dataClass, TransformationContext $context): void + { + foreach ([ + 'include' => $context->include, + 'exclude' => $context->exclude, + 'only' => $context->only, + 'except' => $context->except, + ] as $operation => $tree) { + if ($tree === null) { + continue; + } + + foreach ($tree->nestedProperties as $name) { + $property = $dataClass->properties[$name] ?? null; + + if ($property === null || ($property->hidden && ! $context->constructable)) { + throw CannotPerformPartialOnDataField::missingProperty($operation, $name, $dataClass->name); + } + } + } + } + /** * Merge a reached data instance's partials into its local context. */ @@ -709,17 +841,19 @@ protected function resolveTransformer( } /** - * Get iterable item metadata accepted by the runtime value. + * Get the declared iterable type, with item metadata, that receives the runtime value. */ - protected function iterableTypeForValue(DataProperty $property, mixed $value): ?Type + protected function iterableTypeForValue(DataProperty $property, mixed $value): ?NamedType { foreach ($property->type->getIterableTypes() as $type) { if ($type->acceptsValue($value)) { - return $type->iterableItemType; + return $type; } } - return null; + // A lazy closure's result is not checked against the declared container, so data items in another + // iterable are still transformed rather than output as objects. + return is_iterable($value) ? $property->type->getDataCollectableType() : null; } /** @@ -835,7 +969,7 @@ protected function propagatePartials( $definitions = $context->partialsForNestedProperty($property); - if (! self::hasResolvedPartials($definitions)) { + if (! PartialsDefinition::hasResolved($definitions)) { return; } @@ -856,7 +990,7 @@ protected function propagateIterablePartials( $definitions = $context->partialsForNestedProperty($property); - if (! self::hasResolvedPartials($definitions)) { + if (! PartialsDefinition::hasResolved($definitions)) { return; } @@ -867,19 +1001,6 @@ protected function propagateIterablePartials( } } - /** - * Determine whether a resolved partial set contains any definitions. - * - * @param array{include: list, exclude: list, only: list, except: list} $definitions - */ - private static function hasResolvedPartials(array $definitions): bool - { - return $definitions['include'] !== [] - || $definitions['exclude'] !== [] - || $definitions['only'] !== [] - || $definitions['except'] !== []; - } - /** * Apply only and except selections to a plain array value. * diff --git a/src/data/src/Support/Transformation/PartialTree.php b/src/data/src/Support/Transformation/PartialTree.php index 84bf7760d4..317f147fc0 100644 --- a/src/data/src/Support/Transformation/PartialTree.php +++ b/src/data/src/Support/Transformation/PartialTree.php @@ -11,12 +11,14 @@ /** * Create a compiled partial tree. * - * @param array $children + * @param array $children numeric segments, which select array items, are integer keys + * @param list $nestedProperties the children whose selection continues into their own value */ private function __construct( public bool $selected, public bool $all, public array $children, + public array $nestedProperties = [], ) { } @@ -61,7 +63,7 @@ public function selects(string $property): bool /** * Get the nested selection for a property. */ - public function child(string $property): ?self + public function child(string|int $property): ?self { if (isset($this->children[$property])) { return $this->children[$property]; @@ -99,6 +101,7 @@ public function merge(?self $other): self $this->selected || $other->selected, $this->all || $other->all, $children, + array_values(array_unique([...$this->nestedProperties, ...$other->nestedProperties])), ); } @@ -179,7 +182,7 @@ private static function parse(string $path): array /** * Insert one parsed path into the mutable build tree. * - * @param array{selected: bool, all: bool, children: array} $tree + * @param array{selected: bool, all: bool, children: array} $tree * @param non-empty-list $segments */ private static function insert(array &$tree, array $segments): void @@ -210,17 +213,23 @@ private static function insert(array &$tree, array $segments): void /** * Hydrate an immutable tree from its mutable build representation. * - * @param array{selected: bool, all: bool, children: array} $tree + * @param array{selected: bool, all: bool, children: array} $tree */ private static function hydrate(array $tree, bool $inheritedAll = false): self { $all = $inheritedAll || $tree['all']; $children = []; + $nestedProperties = []; foreach ($tree['children'] as $property => $child) { $children[$property] = self::hydrate($child, $all); + + // A child's own terminal * continues the selection; one inherited from its parent does not. + if ($child['children'] !== [] || $child['all']) { + $nestedProperties[] = (string) $property; + } } - return new self($tree['selected'], $all, $children); + return new self($tree['selected'], $all, $children, $nestedProperties); } } diff --git a/src/data/src/Support/Transformation/TransformationContext.php b/src/data/src/Support/Transformation/TransformationContext.php index 5807c79704..dc07e0d551 100644 --- a/src/data/src/Support/Transformation/TransformationContext.php +++ b/src/data/src/Support/Transformation/TransformationContext.php @@ -34,6 +34,7 @@ public function __construct( public WrapExecutionType $wrapExecutionType = WrapExecutionType::Disabled, public int $depth = 0, public ?int $maxDepth = null, + public bool $throwWhenMaxDepthReached = true, ) { } @@ -76,6 +77,7 @@ public function withMergedPartials(array $partialDefinitions): self wrapExecutionType: $this->wrapExecutionType, depth: $this->depth, maxDepth: $this->maxDepth, + throwWhenMaxDepthReached: $this->throwWhenMaxDepthReached, ); } @@ -97,6 +99,7 @@ public function withWrapExecutionType(WrapExecutionType $wrapExecutionType): sel wrapExecutionType: $wrapExecutionType, depth: $this->depth, maxDepth: $this->maxDepth, + throwWhenMaxDepthReached: $this->throwWhenMaxDepthReached, ); } @@ -145,6 +148,7 @@ public function child( wrapExecutionType: $wrapExecutionType ?? $this->wrapExecutionType, depth: $this->depth + 1, maxDepth: $this->maxDepth, + throwWhenMaxDepthReached: $this->throwWhenMaxDepthReached, ); } diff --git a/src/data/src/Support/Transformation/TransformationContextFactory.php b/src/data/src/Support/Transformation/TransformationContextFactory.php index 030720ba5f..e8139ea79d 100644 --- a/src/data/src/Support/Transformation/TransformationContextFactory.php +++ b/src/data/src/Support/Transformation/TransformationContextFactory.php @@ -31,6 +31,8 @@ class TransformationContextFactory implements Transient protected ?int $maxDepth; + protected bool $throwWhenMaxDepthReached; + protected readonly ?int $configuredMaxDepth; protected PartialsDefinition $partialDefinitions; @@ -42,6 +44,7 @@ public function __construct(DataConfig $config) { $this->configuredMaxDepth = $config->maxTransformationDepth; $this->maxDepth = $this->configuredMaxDepth; + $this->throwWhenMaxDepthReached = $config->throwWhenMaxTransformationDepthReached; $this->partialDefinitions = new PartialsDefinition; } @@ -100,6 +103,7 @@ public function get(object $data): TransformationContext transformers: $this->transformers, wrapExecutionType: $this->wrapExecutionType, maxDepth: $this->maxDepth, + throwWhenMaxDepthReached: $this->throwWhenMaxDepthReached, ); } @@ -127,6 +131,7 @@ public function get(object $data): TransformationContext transformers: $this->transformers, wrapExecutionType: $this->wrapExecutionType, maxDepth: $this->maxDepth, + throwWhenMaxDepthReached: $this->throwWhenMaxDepthReached, ); } @@ -214,10 +219,13 @@ public function withTransformer(string $transformable, Transformer|string $trans /** * Set the maximum nested transformation depth. + * + * @param bool $throw Whether reaching the depth throws instead of returning an empty array */ - public function maxDepth(?int $maxDepth): static + public function maxDepth(?int $maxDepth, bool $throw = true): static { $this->maxDepth = $maxDepth; + $this->throwWhenMaxDepthReached = $throw; return $this; } diff --git a/src/data/src/Support/Types/PhpDocTypeNameResolver.php b/src/data/src/Support/Types/PhpDocTypeNameResolver.php index 0ff10cde14..81b1fe0b96 100644 --- a/src/data/src/Support/Types/PhpDocTypeNameResolver.php +++ b/src/data/src/Support/Types/PhpDocTypeNameResolver.php @@ -10,8 +10,8 @@ class PhpDocTypeNameResolver { - /** @var array>> */ - protected array $imports = []; + /** @var array>, namespaces: array}> */ + protected array $sources = []; /** * Resolve a PHPDoc type name in its declaring class context. @@ -28,11 +28,11 @@ public function resolve(string $name, ReflectionClass $class): string return $name; } - $namespace = $class->getNamespaceName(); + $namespace = $this->namespaceFor($class); $sameNamespace = $namespace === '' ? $name : "{$namespace}\\{$name}"; [$alias, $suffix] = array_pad(explode('\\', $name, 2), 2, null); - $import = $this->importsFor($class)[$alias] ?? null; + $import = $this->importsFor($class, $namespace)[$alias] ?? null; if ($import !== null) { return $suffix === null ? $import : "{$import}\\{$suffix}"; @@ -42,12 +42,40 @@ public function resolve(string $name, ReflectionClass $class): string } /** - * Get the class imports declared by the source file. + * Get the namespace a class is declared in. + * + * An anonymous class is named after its parent, so its namespace comes from its position in the source file. + * + * @param ReflectionClass $class + */ + protected function namespaceFor(ReflectionClass $class): string + { + $file = $class->getFileName(); + + if (! $class->isAnonymous() || $file === false) { + return $class->getNamespaceName(); + } + + $namespace = ''; + + foreach ($this->source($file)['namespaces'] as $line => $declaredNamespace) { + if ($line > $class->getStartLine()) { + break; + } + + $namespace = $declaredNamespace; + } + + return $namespace; + } + + /** + * Get the class imports declared for a namespace by the source file. * * @param ReflectionClass $class * @return array */ - protected function importsFor(ReflectionClass $class): array + protected function importsFor(ReflectionClass $class, string $namespace): array { $file = $class->getFileName(); @@ -55,17 +83,25 @@ protected function importsFor(ReflectionClass $class): array return []; } - $imports = $this->imports[$file] ??= $this->parseImports($file); + return $this->source($file)['imports'][$namespace] ?? []; + } - return $imports[$class->getNamespaceName()] ?? []; + /** + * Get the parsed imports and namespace declarations of a source file. + * + * @return array{imports: array>, namespaces: array} + */ + protected function source(string $file): array + { + return $this->sources[$file] ??= $this->parseSource($file); } /** - * Parse class imports from a PHP source file. + * Parse class imports and namespace declaration lines from a PHP source file. * - * @return array> + * @return array{imports: array>, namespaces: array} */ - protected function parseImports(string $file): array + protected function parseSource(string $file): array { $source = file_get_contents($file); @@ -75,6 +111,7 @@ protected function parseImports(string $file): array $tokens = PhpToken::tokenize($source); $imports = []; + $namespaces = []; $namespace = ''; $namespaceDepth = 0; $braceDepth = 0; @@ -84,6 +121,7 @@ protected function parseImports(string $file): array if ($token->id === T_NAMESPACE && $braceDepth === 0) { [$namespace, $delimiterIndex] = $this->parseNamespace($tokens, $index + 1); + $namespaces[$token->line] = $namespace; $delimiter = $tokens[$delimiterIndex]->text; $namespaceDepth = $delimiter === '{' ? 1 : 0; $braceDepth = $namespaceDepth; @@ -120,7 +158,7 @@ protected function parseImports(string $file): array $index = $delimiterIndex; } - return $imports; + return ['imports' => $imports, 'namespaces' => $namespaces]; } /** diff --git a/src/data/src/Support/Validation/CompiledValidation.php b/src/data/src/Support/Validation/CompiledValidation.php index 88f51a0976..1ff8b535b6 100644 --- a/src/data/src/Support/Validation/CompiledValidation.php +++ b/src/data/src/Support/Validation/CompiledValidation.php @@ -4,6 +4,8 @@ namespace Hypervel\Data\Support\Validation; +use Hypervel\Validation\ValidationData; + /** * Immutable output from one complete validation-rule compilation. */ @@ -32,16 +34,19 @@ public function __construct( /** * Restore only values deliberately excluded from validation. * + * Values come from the validator's own data, which has already dropped everything an exclusion + * rule removed, so an excluded parent is never recreated. + * * @param array $payload - * @param array $sourcePayload + * @param array $validatorData the validator's data, with its encoded keys * @return array */ - public function restorePreservedValues(array $payload, array $sourcePayload): array + public function restorePreservedValues(array $payload, array $validatorData): array { foreach ($this->preservedPaths as $path) { $this->restoreValueAtPath( $payload, - $sourcePayload, + $validatorData, $path->rawSegments(), ); } @@ -50,7 +55,7 @@ public function restorePreservedValues(array $payload, array $sourcePayload): ar } /** - * Restore one exact or wildcard path from the source payload. + * Restore one exact or wildcard path from the validator's data. * * @param list $segments */ @@ -61,7 +66,7 @@ private function restoreValueAtPath( int $offset = 0, ): void { if ($offset === count($segments)) { - $target = $source; + $target = is_array($source) ? ValidationData::decodeKeys($source) : $source; return; } @@ -73,7 +78,9 @@ private function restoreValueAtPath( $segment = $segments[$offset]; if ($segment === null) { - foreach ($source as $key => $value) { + foreach ($source as $encodedKey => $value) { + $key = is_int($encodedKey) ? $encodedKey : ValidationData::replacePlaceholderInString($encodedKey); + if (! is_array($target)) { $target = []; } @@ -93,11 +100,13 @@ private function restoreValueAtPath( return; } - if (! array_key_exists($segment, $source)) { + $encodedSegment = ValidationData::encodeKey($segment); + + if (! array_key_exists($encodedSegment, $source)) { return; } - $value = $source[$segment]; + $value = $source[$encodedSegment]; $nextOffset = $offset + 1; // A failed descent must not materialize an empty container in the validated payload. diff --git a/src/data/src/Support/Validation/DataValidationCompiler.php b/src/data/src/Support/Validation/DataValidationCompiler.php index f438d7d3f2..7e1eef687e 100644 --- a/src/data/src/Support/Validation/DataValidationCompiler.php +++ b/src/data/src/Support/Validation/DataValidationCompiler.php @@ -4,18 +4,35 @@ namespace Hypervel\Data\Support\Validation; +use BackedEnum; +use Closure; use DateTimeInterface; use Hypervel\Contracts\Container\Container; +use Hypervel\Data\Attributes\Validation\ArrayType; +use Hypervel\Data\Attributes\Validation\BooleanType; +use Hypervel\Data\Attributes\Validation\Enum; +use Hypervel\Data\Attributes\Validation\IntegerType; +use Hypervel\Data\Attributes\Validation\Nullable; +use Hypervel\Data\Attributes\Validation\Numeric; +use Hypervel\Data\Attributes\Validation\Present; +use Hypervel\Data\Attributes\Validation\Required; +use Hypervel\Data\Attributes\Validation\Rule; +use Hypervel\Data\Attributes\Validation\Sometimes; +use Hypervel\Data\Attributes\Validation\StringType; +use Hypervel\Data\Attributes\Validation\ValidationAttribute; use Hypervel\Data\Contracts\BaseData; use Hypervel\Data\Exceptions\CannotBuildValidationRule; use Hypervel\Data\Lazy; use Hypervel\Data\Optional; +use Hypervel\Data\RuleInferrers\RuleInferrer; use Hypervel\Data\Support\Creation\ConstructionState; use Hypervel\Data\Support\Creation\CreationMode; use Hypervel\Data\Support\DataClass; use Hypervel\Data\Support\DataClassRepository; +use Hypervel\Data\Support\DataConfig; use Hypervel\Data\Support\DataProperty; use Hypervel\Data\Support\Types\NamedType; +use Hypervel\Data\Support\Validation\References\FieldReference; use Hypervel\Validation\Rules\RequiredIf as NativeRequiredIf; use Hypervel\Validation\Rules\RequiredUnless as NativeRequiredUnless; use TypeError; @@ -41,6 +58,23 @@ class DataValidationCompiler 'required_without_all', ]; + /** + * The attribute type of each inferred rule, which a declared attribute of that type replaces. + * + * @var array> + */ + protected const array INFERRED_RULE_TYPES = [ + 'sometimes' => Sometimes::class, + 'nullable' => Nullable::class, + 'present' => Present::class, + 'required' => Required::class, + 'array' => ArrayType::class, + 'boolean' => BooleanType::class, + 'numeric' => Numeric::class, + 'integer' => IntegerType::class, + 'string' => StringType::class, + ]; + /** * Create a data validation compiler. */ @@ -48,6 +82,8 @@ public function __construct( protected readonly DataClassRepository $dataClasses, protected readonly Container $container, protected readonly RuleDenormalizer $ruleDenormalizer, + protected readonly RuleNormalizer $ruleNormalizer, + protected readonly DataConfig $config, ) { } @@ -56,6 +92,8 @@ public function __construct( */ public function compile(ConstructionState $state): CompiledValidation { + $rootDataClass = $this->dataClasses->get($state->nodeClass() ?? $state->context->dataClass); + $state->setRuleInferrers($this->resolveRuleInferrers()); $accumulator = new ValidationAccumulator; $compileUnknownFields = $state->unknownInput() !== null; $lifecycleDeclarations = [ @@ -64,13 +102,14 @@ public function compile(ConstructionState $state): CompiledValidation ]; $this->compileNode( - $state->nodeClass() ?? $state->context->dataClass, + $rootDataClass->name, $state, ValidationPath::create(), ValidationPath::create(), $accumulator, $lifecycleDeclarations, $compileUnknownFields, + $rootDataClass, ); $this->appendStructuralMarkers($accumulator, $state->payload()); @@ -78,7 +117,7 @@ public function compile(ConstructionState $state): CompiledValidation rules: $accumulator->rules, messages: $accumulator->messages, attributes: $accumulator->attributes, - preservedPaths: $accumulator->preservedPaths, + preservedPaths: $this->preservedPaths($accumulator), additionalFields: $accumulator->additionalFields, allowedSubtrees: $accumulator->allowedSubtrees, ); @@ -93,6 +132,7 @@ public function compileCollection( ConstructionState $state, string $dataClass, ): CompiledValidation { + $state->setRuleInferrers($this->resolveRuleInferrers()); $accumulator = new ValidationAccumulator; $compileUnknownFields = $state->unknownInput() !== null; $lifecycleDeclarations = [ @@ -109,6 +149,7 @@ public function compileCollection( $accumulator, $lifecycleDeclarations, $compileUnknownFields, + null, ); $this->appendStructuralMarkers($accumulator, $state->payload()); @@ -116,12 +157,58 @@ public function compileCollection( rules: $accumulator->rules, messages: $accumulator->messages, attributes: $accumulator->attributes, - preservedPaths: $accumulator->preservedPaths, + preservedPaths: $this->preservedPaths($accumulator), additionalFields: $accumulator->additionalFields, allowedSubtrees: $accumulator->allowedSubtrees, ); } + /** + * Get the paths restored after validation. + * + * A constructor input is restored only when no compiled rule applies to it or to anything beneath it; + * otherwise the validated result owns it. + * + * @return list + */ + protected function preservedPaths(ValidationAccumulator $accumulator): array + { + if ($accumulator->constructorInputPaths === []) { + return $accumulator->preservedPaths; + } + + $rules = []; + + foreach (array_keys($accumulator->rules) as $rule) { + $rules[] = ValidationPath::create((string) $rule)->rawSegments(); + } + + $paths = $accumulator->preservedPaths; + + foreach ($accumulator->constructorInputPaths as $input) { + $segments = $input->rawSegments(); + + foreach ($rules as $rule) { + if (count($rule) < count($segments)) { + continue; + } + + // A null segment is a wildcard and matches any item on either side. + foreach ($segments as $index => $segment) { + if ($segment !== null && $rule[$index] !== null && $segment !== $rule[$index]) { + continue 2; + } + } + + continue 2; + } + + $paths[] = $input; + } + + return $paths; + } + /** * Compile one data node into the root rule graph. * @@ -136,10 +223,37 @@ protected function compileNode( ValidationAccumulator $accumulator, array &$lifecycleDeclarations, bool $compileUnknownFields, + ?DataClass $rootDataClass, bool $observed = true, ): void { $dataClass = $this->dataClasses->get($class); + $resolveField = fn (FieldReference $reference, ValidationPath $referencePath): string => $this->resolveFieldReference( + $reference, + $referencePath, + $dataClass, + $rootDataClass, + $state->context->mapPropertyNames, + ); $contextualProperties = $dataClass->contextualParameters; + $contextualPrepared = $observed && $contextualProperties !== [] && $state->contextualValuesPrepared(); + // Class rules and configured inferrers see the node's actual input and path, while rules compile at $path. + // An unobserved node has no input, as Fill never entered it. + $context = $dataClass->hasLifecycleMethod('rules') || $this->config->ruleInferrers !== [] + ? new ValidationContext( + payload: $observed ? $state->currentPayload() : [], + fullPayload: $state->payload(), + path: $observed ? new ValidationPath($state->path()) : $path, + ) + : null; + + foreach ($dataClass->constructorInputs as $input) { + $inputPath = $path->property($input); + $accumulator->constructorInputPaths[] = $inputPath; + + if ($compileUnknownFields) { + $accumulator->allowedSubtrees[] = $inputPath->get(); + } + } foreach ($dataClass->properties as $property) { if ($property->computed) { @@ -151,7 +265,8 @@ protected function compileNode( $propertyPath = $path->property($wireKey); $structuralPropertyPath = $structuralPath->property($wireKey); - if (isset($contextualProperties[$property->name])) { + // Fill prepares contextual values only for nodes it reaches, so a missing child's template has none to check. + if (isset($contextualProperties[$property->name]) && ! $contextualPrepared) { if ($compileUnknownFields) { $this->recordAuxiliaryPath( $property, @@ -184,6 +299,18 @@ protected function compileNode( $accumulator->preservedPaths[] = $propertyPath; $accumulator->finishedStructuralPaths[$structuralPropertyPath->get()] = true; + // A finished object is not checked against inferred input rules or validated internally, but the + // property's declared rules, such as an exclusion, still apply to it. + $declaredRules = []; + + foreach ($this->declaredRuleAttributes($property) as $attribute) { + array_push($declaredRules, ...$this->ruleDenormalizer->execute($attribute, $path, $resolveField)); + } + + if ($declaredRules !== []) { + $accumulator->rules[$propertyPath->get()] = $declaredRules; + } + if ($compileUnknownFields) { $accumulator->allowedSubtrees[] = $propertyPath->get(); } @@ -191,21 +318,30 @@ protected function compileNode( continue; } - $nestedDataClass = $property->type->getDataObjectClass(); - $dataIterable = $property->type->getDataCollectableType(); - $dataIterableClass = $dataIterable?->dataClass; + $dataType = $this->receivingDataType($property, $state, $hasValue, $value); + $nestedDataClass = $dataType !== null && $dataType->kind->isDataObject() ? $dataType->dataClass : null; + $dataIterableClass = $dataType !== null && $dataType->kind->isDataCollectable() ? $dataType->dataClass : null; $inferredRequired = false; $propertyRulePath = $propertyPath->get(); $accumulator->rules[$propertyRulePath] = $this->propertyRules( $property, $path, + $context, $propertyPath, + $hasValue, $value, $state, - $nestedDataClass !== null || $dataIterable !== null, + $this->inferredTypeRule($property, $hasValue, $value, $dataType), + $dataIterableClass !== null, $inferredRequired, + $resolveField, ); + // An abstract node remains only when Fill could not resolve its morph, so its discriminator is invalid. + if ($property->morphable && $dataClass->isAbstract) { + $accumulator->rules[$propertyRulePath][] = new EnsurePropertyMorphable; + } + if (! $propertyPath->equals($structuralPropertyPath)) { $accumulator->addMarkerCandidate( $structuralPropertyPath, @@ -222,6 +358,26 @@ protected function compileNode( } if (! $hasValue || $value === null || $value instanceof Optional) { + // A missing or null object that does not accept null still reports its own required fields, + // as flat Laravel rules would. An omitted Optional or defaulted object is not required. + if ($nestedDataClass !== null + && $value === null + && ! $property->type->isNullable + && ($hasValue || (! $property->type->isOptional && ! $property->hasDefaultValue)) + ) { + $this->compileNode( + $nestedDataClass, + $state, + $propertyPath, + $structuralPropertyPath, + $accumulator, + $lifecycleDeclarations, + $compileUnknownFields, + $rootDataClass, + observed: false, + ); + } + continue; } @@ -237,6 +393,7 @@ protected function compileNode( $accumulator, $lifecycleDeclarations, $compileUnknownFields, + $rootDataClass, ); } finally { $state->leave(); @@ -256,6 +413,17 @@ protected function compileNode( $accumulator, $lifecycleDeclarations, $compileUnknownFields, + $rootDataClass, + ); + + continue; + } + + if ($nestedDataClass !== null || $dataIterableClass !== null) { + // Input Fill could not read must fail here, and a blank string skips every non-implicit rule. + $accumulator->rules[$propertyRulePath] = $this->mergeRules( + ['required'], + $accumulator->rules[$propertyRulePath], ); } } @@ -264,9 +432,11 @@ protected function compileNode( $dataClass, $state, $path, + $context, $structuralPath, $accumulator, $observed, + $resolveField, ); if ($state->context->mode !== CreationMode::Rules) { @@ -299,6 +469,7 @@ protected function compileDataIterable( ValidationAccumulator $accumulator, array &$lifecycleDeclarations, bool $compileUnknownFields, + ?DataClass $rootDataClass, ): void { $wireKey = $state->originalKey($property->name); $state->enterProperty($property->name, $property->inputPath($wireKey)); @@ -313,6 +484,7 @@ protected function compileDataIterable( $accumulator, $lifecycleDeclarations, $compileUnknownFields, + $rootDataClass, ); } finally { $state->leave(); @@ -335,12 +507,14 @@ protected function compileDataIterableValues( ValidationAccumulator $accumulator, array &$lifecycleDeclarations, bool $compileUnknownFields, + ?DataClass $rootDataClass, ): void { - $hasFinishedValues = false; + // A finished object, or input Fill could not read, is checked at its own item path. + $hasNonArrayValues = false; foreach ($values as $value) { - if ($value instanceof $dataClass) { - $hasFinishedValues = true; + if (! is_array($value)) { + $hasNonArrayValues = true; break; } @@ -355,6 +529,7 @@ protected function compileDataIterableValues( $accumulator, $lifecycleDeclarations, $compileUnknownFields, + $rootDataClass, observed: false, ); @@ -362,7 +537,7 @@ protected function compileDataIterableValues( } if ($state->isCurrentCollectionUniform() - && ! $hasFinishedValues + && ! $hasNonArrayValues ) { $firstKey = array_key_first($values); $state->enterItem($firstKey); @@ -379,6 +554,7 @@ protected function compileDataIterableValues( $accumulator, $lifecycleDeclarations, $compileUnknownFields, + $rootDataClass, ); return; @@ -395,6 +571,7 @@ protected function compileDataIterableValues( $structuralPath, $lifecycleDeclarations, $compileUnknownFields, + $rootDataClass, ); if ($speculative !== null) { @@ -418,6 +595,13 @@ protected function compileDataIterableValues( continue; } + if (! is_array($value)) { + // A blank string skips every non-implicit rule, so `required` rejects it. + $accumulator->rules[$itemPath->get()] = ['required', 'array']; + + continue; + } + $state->enterItem($key); $itemAccumulator = new ValidationAccumulator; @@ -430,6 +614,7 @@ protected function compileDataIterableValues( $itemAccumulator, $lifecycleDeclarations, $compileUnknownFields, + $rootDataClass, ); } finally { $state->leave(); @@ -454,6 +639,7 @@ protected function compileUniformDynamicIterable( ValidationPath $structuralPath, array &$lifecycleDeclarations, bool $compileUnknownFields, + ?DataClass $rootDataClass, ): ?ValidationAccumulator { $compiled = null; @@ -470,6 +656,7 @@ protected function compileUniformDynamicIterable( $itemAccumulator, $lifecycleDeclarations, $compileUnknownFields, + $rootDataClass, ); } finally { $state->leave(); @@ -490,31 +677,27 @@ protected function compileUniformDynamicIterable( } /** - * Infer fixed presence and type rules for one property. + * Build the rules for one property: inferred presence and type rules, then declared attributes. * - * @return list + * @param null|ValidationContext $context the node's context, present when class rules or configured inferrers use it + * @param null|'array'|'boolean'|'integer'|'numeric'|'string'|Enum $typeRule + * @param Closure(FieldReference, ValidationPath): string $resolveField + * @return list */ protected function propertyRules( DataProperty $property, ValidationPath $nodePath, + ?ValidationContext $context, ValidationPath $propertyPath, + bool $hasValue, mixed $value, ConstructionState $state, - bool $expectsArray, + string|Enum|null $typeRule, + bool $receivesDataCollection, bool &$inferredRequired, + Closure $resolveField, ): array { - $attributeRules = []; - $hasPresenceRule = false; - - foreach ($property->attributes->all(ValidationRule::class) as $recipe) { - $attribute = $recipe->newInstance(); - $denormalizedRules = $this->ruleDenormalizer->execute($attribute, $nodePath); - $hasPresenceRule = $hasPresenceRule - || $attribute instanceof RequiringRule - || $this->hasPresenceRule($denormalizedRules); - array_push($attributeRules, ...$denormalizedRules); - } - + $attributes = $this->declaredRuleAttributes($property); $generatedRules = null; foreach ($state->context->beforeRulesHooks as $hook) { @@ -525,74 +708,242 @@ protected function propertyRules( } } - if ($generatedRules === null) { - $generatedRules = $this->inferRules($property, $expectsArray, $hasPresenceRule); - $inferredRequired = in_array('required', $generatedRules, true); + if ($generatedRules === null && $this->config->ruleInferrers !== []) { + $evaluatedRules = []; + $hasPresenceRule = false; + + foreach ($attributes as $attribute) { + if ($attribute instanceof RequiringRule) { + $hasPresenceRule = true; + } elseif ($attribute instanceof ValidationAttribute) { + // A keyword shows presence without resolving parameters an inferrer may remove. + $hasPresenceRule = $hasPresenceRule + || in_array($attribute::keyword(), self::PRESENCE_RULES, true); + } else { + $evaluatedRules[spl_object_id($attribute)] = $denormalizedRules = $this->ruleDenormalizer->execute($attribute, $nodePath, $resolveField); + $hasPresenceRule = $hasPresenceRule || $this->hasPresenceRule($denormalizedRules); + } + } + + $inferredRules = $this->inferRules($property, $typeRule, $hasPresenceRule, $receivesDataCollection, $hasValue); + $propertyRules = $this->applyRuleInferrers($property, $attributes, $inferredRules, $context, $state); + // An inferrer may remove the inferred requirement; class rules must not treat it as present. + $inferredRequired = in_array('required', $inferredRules, true) + && $propertyRules->hasType(Required::class); + $rules = []; + + // Surviving rules reuse any evaluation needed for presence; removed keyword attributes never resolve. + foreach ($propertyRules->all() as $rule) { + array_push( + $rules, + ...($evaluatedRules[spl_object_id($rule)] ?? $this->ruleDenormalizer->execute($rule, $nodePath, $resolveField)), + ); + } } else { - $generatedRules = $this->ruleDenormalizer->execute($generatedRules, $nodePath); + $attributeRules = []; + $hasPresenceRule = false; + $hasEnumAttribute = false; + + foreach ($attributes as $attribute) { + $denormalizedRules = $this->ruleDenormalizer->execute($attribute, $nodePath, $resolveField); + $hasPresenceRule = $hasPresenceRule + || $attribute instanceof RequiringRule + || $this->hasPresenceRule($denormalizedRules); + $hasEnumAttribute = $hasEnumAttribute || $attribute instanceof Enum; + array_push($attributeRules, ...$denormalizedRules); + } + + if ($generatedRules !== null) { + // Hook rules replace inference, so they come before the declared attributes as inferred rules do. + $rules = $this->mergeRules( + $this->ruleDenormalizer->execute($generatedRules, $nodePath, $resolveField), + $attributeRules, + ); + } else { + $generatedRules = $this->inferRules( + $property, + match (true) { + ! $typeRule instanceof Enum => $typeRule, + // A declared enum rule, with its own case restrictions, replaces the inferred one. + $hasEnumAttribute => null, + default => $typeRule->getRule($nodePath), + }, + $hasPresenceRule, + $receivesDataCollection, + $hasValue, + ); + + // A declared attribute replaces the inferred rule of its type, as PropertyRules does for inferrers. + foreach ($generatedRules as $index => $generatedRule) { + if (! is_string($generatedRule)) { + continue; + } + + foreach ($attributes as $attribute) { + if ($attribute instanceof (self::INFERRED_RULE_TYPES[$generatedRule])) { + unset($generatedRules[$index]); + + break; + } + } + } + + $inferredRequired = in_array('required', $generatedRules, true); + $rules = [...$generatedRules, ...$attributeRules]; + } } - $rules = $this->mergeRules($attributeRules, $generatedRules); foreach ($state->context->afterRulesHooks as $hook) { $rules = $this->ruleDenormalizer->execute( $hook($rules, $property, $propertyPath, $value), $nodePath, + $resolveField, ); } return $rules; } + /** + * Get the fresh validation attributes declared on a property. + * + * A Rule attribute's rules become typed attributes where possible, so they replace inferred rules of their type. + * + * @return list + */ + protected function declaredRuleAttributes(DataProperty $property): array + { + $attributes = []; + + foreach ($property->attributes->all(ValidationRule::class) as $recipe) { + $attribute = $recipe->newInstance(); + + if ($attribute instanceof Rule) { + array_push($attributes, ...$this->ruleNormalizer->execute($attribute)); + } else { + $attributes[] = $attribute; + } + } + + return $attributes; + } + /** * Infer fixed presence and type rules for one property. * - * @return list + * @param null|'array'|'boolean'|'integer'|'numeric'|'string'|object $typeRule a rule name, or a rule object used as given + * @return list<'array'|'boolean'|'integer'|'nullable'|'numeric'|'present'|'required'|'sometimes'|'string'|object> */ protected function inferRules( DataProperty $property, - bool $expectsArray, - bool $hasPresenceRule = false, + string|object|null $typeRule, + bool $hasPresenceRule, + bool $receivesDataCollection, + bool $hasValue, ): array { - $rules = match (true) { - $property->type->isOptional => ['sometimes'], - $property->type->isNullable => ['nullable'], - ! $property->hasDefaultValue && ! $hasPresenceRule => ['required'], - default => [], - }; + $rules = []; - $typeRule = $expectsArray ? 'array' : $this->primitiveRule($property); + if ($property->type->isNullable) { + $rules[] = 'nullable'; + } + + // A declared presence rule must still apply when the key is missing. + if ($property->type->isOptional && ! $hasPresenceRule) { + $rules[] = 'sometimes'; + } + + if (! $property->type->isNullable + && ! $property->type->isOptional + && ! $hasPresenceRule + // A default fills an omitted value, but supplied input must still be present. + && (! $property->hasDefaultValue || $hasValue) + ) { + // A list of data objects may be empty, which `required` would reject. + $rules[] = $receivesDataCollection ? 'present' : 'required'; + } if ($typeRule !== null) { $rules[] = $typeRule; } - if ($rules === []) { + if ($rules === [] && ! $hasPresenceRule) { $rules[] = 'sometimes'; } return $rules; } + /** + * Run the configured rule inferrers over one property's inferred and attribute rules. + * + * Declared attributes follow the inferred rules and replace inferred rules of the same type. + * + * @param list $attributes + * @param list<'array'|'boolean'|'integer'|'nullable'|'numeric'|'present'|'required'|'sometimes'|'string'|Enum> $inferredRules + */ + protected function applyRuleInferrers( + DataProperty $property, + array $attributes, + array $inferredRules, + ValidationContext $context, + ConstructionState $state, + ): PropertyRules { + $rules = new PropertyRules; + + foreach ($inferredRules as $inferredRule) { + $rules->add(is_string($inferredRule) ? new (self::INFERRED_RULE_TYPES[$inferredRule]) : $inferredRule); + } + + if ($attributes !== []) { + // Added together, so several declared requiring rules all remain. + $rules->add(...$attributes); + } + + foreach ($state->ruleInferrers() as $inferrer) { + $rules = $inferrer->handle($property, $rules, $context); + } + + return $rules; + } + + /** + * Resolve the configured rule inferrers for one compilation. + * + * They are resolved per compilation, so scoped inferrers follow the current coroutine. + * + * @return list + */ + protected function resolveRuleInferrers(): array + { + if ($this->config->ruleInferrers === []) { + return []; + } + + return array_map( + fn (string $inferrer): RuleInferrer => $this->container->make($inferrer), + $this->config->ruleInferrers, + ); + } + /** * Apply class-owned rule replacements in PHP property-name space. + * + * @param Closure(FieldReference, ValidationPath): string $resolveField */ protected function applyClassRules( DataClass $dataClass, ConstructionState $state, ValidationPath $path, + ?ValidationContext $context, ValidationPath $structuralPath, ValidationAccumulator $accumulator, bool $observed, + Closure $resolveField, ): void { if (! $dataClass->hasLifecycleMethod('rules')) { return; } - $context = new ValidationContext( - payload: $observed ? $state->currentPayload() : [], - fullPayload: $state->payload(), - path: $path, - ); $classRules = $this->callArrayLifecycleMethod( $dataClass, 'rules', @@ -612,7 +963,7 @@ protected function applyClassRules( ), $accumulator, ); - $classRule = $this->ruleDenormalizer->execute($declaration, $path); + $classRule = $this->ruleDenormalizer->execute($declaration, $path, $resolveField); foreach ($rulePaths as $translatedPath) { $rulePath = $translatedPath->path->get(); @@ -658,6 +1009,7 @@ protected function applyClassRules( unset($accumulator->inferredRequiredPaths[$rulePath]); } + // Class rules follow their declaration order, which decides how overlapping exact and wildcard keys merge. unset($accumulator->rules[$rulePath]); $accumulator->rules[$rulePath] = $rules; } @@ -724,6 +1076,7 @@ protected function applyClassMessagesAndAttributes( $messagePath = $path->wildcard()->property($key); $paths = [new TranslatedValidationPath($messagePath, $messagePath)]; } else { + // A string message's key names a field, optionally followed by a rule. $paths = $this->translateRulePaths( $key, $dataClass, @@ -731,6 +1084,7 @@ protected function applyClassMessagesAndAttributes( $path, $structuralPath, $observed, + allowsRuleSuffix: is_string($message), ); } @@ -785,7 +1139,7 @@ protected function callArrayLifecycleMethod( array $parameters = [], ): array { $result = $this->container->call( - "{$dataClass->name}::{$method}", + [$dataClass->name, $method], $parameters, ); @@ -801,9 +1155,68 @@ protected function callArrayLifecycleMethod( return $result; } + /** + * Resolve a field reference's property names to input names. + * + * Unlike rule keys, references are not filtered, since a condition may read input that has no rules. + */ + protected function resolveFieldReference( + FieldReference $reference, + ValidationPath $path, + DataClass $dataClass, + ?DataClass $rootDataClass, + bool $mapPropertyNames, + ): string { + if ($reference->fromRoot) { + if ($rootDataClass === null) { + return $reference->name; + } + + $dataClass = $rootDataClass; + $path = ValidationPath::create(); + } + + $segments = ValidationPath::create($reference->name)->rawSegments(); + $count = count($segments); + + for ($index = 0; $index < $count; ++$index) { + $segment = $segments[$index]; + $property = is_string($segment) ? ($dataClass->properties[$segment] ?? null) : null; + + // Undeclared names already describe input, including confirmation fields and explicit input paths. + if ($property === null) { + return $this->appendUnmappedSegments($path, $segments, $index)->get(); + } + + $path = $path->property($mapPropertyNames ? ($property->inputMappedName ?? $property->name) : $property->name); + + if ($index + 1 === $count) { + return $path->get(); + } + + $types = [...$property->type->getDataObjectTypes(), ...$property->type->getDataCollectableTypes()]; + + if (count($types) !== 1) { + return $this->appendUnmappedSegments($path, $segments, $index + 1)->get(); + } + + $type = $types[0]; + + if ($type->kind->isDataCollectable()) { + $item = $segments[++$index]; + $path = $item === null ? $path->wildcard() : $path->item($item); + } + + $dataClass = $this->dataClasses->get($type->dataClass); + } + + return $path->get(); + } + /** * Translate a class rule key to its observed wire paths. * + * @param bool $allowsRuleSuffix whether the key may end with a rule, as a string message key may * @return list */ protected function translateRulePaths( @@ -813,6 +1226,7 @@ protected function translateRulePaths( ValidationPath $path, ValidationPath $structuralPath, bool $observed, + bool $allowsRuleSuffix = false, ): array { return $this->translateRuleSegments( ValidationPath::create($key)->rawSegments(), @@ -822,6 +1236,7 @@ protected function translateRulePaths( $path, $structuralPath, $observed, + $allowsRuleSuffix, ); } @@ -839,6 +1254,7 @@ protected function translateRuleSegments( ValidationPath $path, ValidationPath $structuralPath, bool $observed, + bool $allowsRuleSuffix = false, ): array { if (! array_key_exists($offset, $segments)) { return [new TranslatedValidationPath( @@ -861,7 +1277,8 @@ protected function translateRuleSegments( if ($property->computed || ! $property->validate - || isset($dataClass->contextualParameters[$property->name]) + || (isset($dataClass->contextualParameters[$property->name]) + && ! ($observed && $state->contextualValuesPrepared())) ) { return []; } @@ -873,10 +1290,6 @@ protected function translateRuleSegments( $hasValue = $observed && $state->hasValue($inputPath); $value = $hasValue ? $state->getValue($inputPath) : null; - if ($property->isFinishedValue($value)) { - return []; - } - if (! array_key_exists($offset + 1, $segments)) { return [new TranslatedValidationPath( $path, @@ -884,9 +1297,22 @@ protected function translateRuleSegments( )]; } - $nestedDataClass = $property->type->getDataObjectClass(); + if ($property->isFinishedValue($value)) { + return $this->finishedValuePaths($path, $structuralPath, $segments, $offset + 1, $allowsRuleSuffix); + } + + $dataType = $this->receivingDataType($property, $state, $hasValue, $value); + + if ($dataType === null) { + return [new TranslatedValidationPath( + $this->appendUnmappedSegments($path, $segments, $offset + 1), + $this->appendUnmappedSegments($structuralPath, $segments, $offset + 1), + )]; + } - if ($nestedDataClass !== null) { + if ($dataType->kind->isDataObject()) { + /** @var class-string $nestedDataClass */ + $nestedDataClass = $dataType->dataClass; $state->enterProperty($property->name, $inputPath); try { @@ -898,23 +1324,15 @@ protected function translateRuleSegments( $path, $structuralPath, $hasValue && is_array($value), + $allowsRuleSuffix, ); } finally { $state->leave(); } } - $dataIterable = $property->type->getDataCollectableType(); - - if ($dataIterable === null) { - return [new TranslatedValidationPath( - $this->appendUnmappedSegments($path, $segments, $offset + 1), - $this->appendUnmappedSegments($structuralPath, $segments, $offset + 1), - )]; - } - /** @var class-string $itemDataClass */ - $itemDataClass = $dataIterable->dataClass; + $itemDataClass = $dataType->dataClass; $itemSegment = $segments[$offset + 1]; $values = $hasValue && is_array($value) ? $value : []; $state->enterProperty($property->name, $inputPath); @@ -924,7 +1342,13 @@ protected function translateRuleSegments( $itemValue = $values[$itemSegment] ?? null; if ($itemValue instanceof $itemDataClass) { - return []; + return $this->finishedValuePaths( + $path->item($itemSegment), + $structuralPath->item($itemSegment), + $segments, + $offset + 2, + $allowsRuleSuffix, + ); } $state->enterItem($itemSegment); @@ -938,6 +1362,7 @@ protected function translateRuleSegments( $path->item($itemSegment), $structuralPath->item($itemSegment), array_key_exists($itemSegment, $values) && is_array($itemValue), + $allowsRuleSuffix, ); } finally { $state->leave(); @@ -953,20 +1378,22 @@ protected function translateRuleSegments( $path->wildcard(), $structuralPath->wildcard(), false, + $allowsRuleSuffix, ); } - $hasFinishedValues = false; + // As in compilation, a finished object or input Fill could not read has its own item path. + $hasNonArrayValues = false; foreach ($values as $itemValue) { - if ($itemValue instanceof $itemDataClass) { - $hasFinishedValues = true; + if (! is_array($itemValue)) { + $hasNonArrayValues = true; break; } } - if ($state->isCurrentCollectionUniform() && ! $hasFinishedValues) { + if ($state->isCurrentCollectionUniform() && ! $hasNonArrayValues) { $firstKey = array_key_first($values); $state->enterItem($firstKey); @@ -982,6 +1409,7 @@ protected function translateRuleSegments( $path->wildcard(), $structuralPath->wildcard(), is_array($values[$firstKey]), + $allowsRuleSuffix, ); } } finally { @@ -993,6 +1421,14 @@ protected function translateRuleSegments( foreach ($values as $itemKey => $itemValue) { if ($itemValue instanceof $itemDataClass) { + array_push($paths, ...$this->finishedValuePaths( + $path->item($itemKey), + $structuralPath->wildcard(), + $segments, + $offset + 2, + $allowsRuleSuffix, + )); + continue; } @@ -1007,6 +1443,7 @@ protected function translateRuleSegments( $path->item($itemKey), $structuralPath->wildcard(), is_array($itemValue), + $allowsRuleSuffix, )); } finally { $state->leave(); @@ -1019,6 +1456,34 @@ protected function translateRuleSegments( } } + /** + * Translate the rest of a key that reaches a finished value. + * + * A finished value is not validated internally, so only a key ending at the value applies, or a message key + * ending with one rule for it. + * + * @param list $segments + * @return list + */ + protected function finishedValuePaths( + ValidationPath $path, + ValidationPath $structuralPath, + array $segments, + int $offset, + bool $allowsRuleSuffix, + ): array { + if (array_key_exists($offset + 1, $segments) + || (array_key_exists($offset, $segments) && ! $allowsRuleSuffix) + ) { + return []; + } + + return [new TranslatedValidationPath( + $this->appendUnmappedSegments($path, $segments, $offset), + $this->appendUnmappedSegments($structuralPath, $segments, $offset), + )]; + } + /** * Append rule segments that no longer describe Data properties. * @@ -1193,32 +1658,79 @@ protected function usesDynamicRules( ): bool { return $this->dataClasses->hasDynamicRuleGraph($dataClass) || $state->context->beforeRulesHooks !== [] - || $state->context->afterRulesHooks !== []; + || $state->context->afterRulesHooks !== [] + || $this->config->ruleInferrers !== []; } /** - * Get one unambiguous primitive validation rule. + * Get one unambiguous type validation rule. + * + * @return null|'array'|'boolean'|'integer'|'numeric'|'string'|Enum */ - protected function primitiveRule(DataProperty $property): ?string + protected function primitiveRule(DataProperty $property): string|Enum|null { $rules = []; foreach ($property->type->getNamedTypes() as $type) { - $rule = match ($type->name) { - 'array', 'iterable' => 'array', - 'bool', 'false', 'true' => 'boolean', - 'float' => 'numeric', - 'int' => 'integer', - 'string' => 'string', - default => null, - }; + $rule = $this->typeRuleFor($type); if ($rule !== null) { - $rules[$rule] = true; + $rules[$rule instanceof Enum ? $type->name : $rule] = $rule; } } - return count($rules) === 1 ? array_key_first($rules) : null; + return count($rules) === 1 ? reset($rules) : null; + } + + /** + * Get the type rule for one declared named type. + * + * @return null|'array'|'boolean'|'integer'|'numeric'|'string'|Enum + */ + protected function typeRuleFor(NamedType $type): string|Enum|null + { + return match ($type->name) { + 'array', 'iterable' => 'array', + 'bool', 'false', 'true' => 'boolean', + 'float' => 'numeric', + 'int' => 'integer', + 'string' => 'string', + default => ! $type->builtIn && is_a($type->name, BackedEnum::class, true) + ? new Enum($type->name) + : null, + }; + } + + /** + * Get the inferred type rule for one property value. + * + * A union value uses the rule of the declared type that holds it, so numeric size rules keep + * their numeric meaning. Other values use the declaration's rule. + * + * @return null|'array'|'boolean'|'integer'|'numeric'|'string'|Enum + */ + protected function inferredTypeRule( + DataProperty $property, + bool $hasValue, + mixed $value, + ?NamedType $dataType, + ): string|Enum|null { + if ($hasValue && $value !== null && ! $value instanceof Optional && $property->type->isUnion) { + // Fill replaced a data value with its normalized input, so the type it selected decides. + if ($dataType !== null && $property->type->recordsSelectedType) { + return 'array'; + } + + foreach ($property->type->getNamedTypes() as $type) { + if (! $type->kind->isDataRelated() && $type->acceptsValue($value)) { + return $this->typeRuleFor($type); + } + } + } + + return $property->type->getDataObjectClass() !== null || $property->type->getDataCollectableType() !== null + ? 'array' + : $this->primitiveRule($property); } /** @@ -1238,6 +1750,27 @@ protected function wireKey( : $property->name; } + /** + * Get the data object or data collection type that receives a property's value on the current node. + * + * A union value uses the type Fill selected from the raw value, because Fill replaced a data value + * with its normalized input. Without a value, the declaration's one data type applies. + */ + protected function receivingDataType( + DataProperty $property, + ConstructionState $state, + bool $hasValue, + mixed $value, + ): ?NamedType { + if ($property->type->recordsSelectedType && $hasValue && $value !== null && ! $value instanceof Optional) { + $type = $state->selectedType($property->name); + + return $type !== null && $type->kind->isDataRelated() ? $type : null; + } + + return $property->type->getDataObjectType() ?? $property->type->getDataCollectableType(); + } + /** * Record an exact field or opaque subtree excluded from rule compilation. */ diff --git a/src/data/src/Support/Validation/DataValidator.php b/src/data/src/Support/Validation/DataValidator.php index b7b0d93711..27b0b5a3f7 100644 --- a/src/data/src/Support/Validation/DataValidator.php +++ b/src/data/src/Support/Validation/DataValidator.php @@ -18,8 +18,10 @@ use Hypervel\Data\Support\DataClassRepository; use Hypervel\Foundation\Precognition; use Hypervel\Http\Request; +use Hypervel\Support\Str; use Hypervel\Validation\UnknownFields; use Hypervel\Validation\ValidationException; +use Hypervel\Validation\Validator as ValidationValidator; use TypeError; class DataValidator @@ -66,7 +68,7 @@ public function authorize( return $request; } - $result = $this->container->call("{$class}::authorize"); + $result = $this->container->call([$class, 'authorize']); if (! is_bool($result) && ! $result instanceof Response) { throw new TypeError(sprintf( @@ -127,6 +129,16 @@ public function validate( $compiled->messages, $compiled->attributes, ); + + // Uniform collections compile to wildcard rules, whose expanded attributes the validator would + // otherwise name unformatted. Formatting them as explicit rules keeps messages the same either way; + // user hooks run later and may replace it. + if ($validator instanceof ValidationValidator) { + $validator->setImplicitAttributesFormatter( + static fn (string $attribute): string => str_replace('_', ' ', Str::snake($attribute)), + ); + } + $unfilteredRules = null; if ($request?->isPrecognitive()) { @@ -163,7 +175,7 @@ public function validate( $payload = $this->restoreSourceKeyOrder( $compiled->restorePreservedValues( $validator->validate(), - $sourcePayload, + $validator->getData(), ), $sourcePayload, ); @@ -222,7 +234,7 @@ protected function configureValidator( if ($dataClass->hasLifecycleMethod('withValidator')) { $this->container->call( - "{$class}::withValidator", + [$class, 'withValidator'], ['validator' => $validator], ); } @@ -236,7 +248,7 @@ protected function configureValidator( } $callbacks = $this->container->call( - "{$class}::after", + [$class, 'after'], ['validator' => $validator], ); @@ -310,9 +322,7 @@ protected function resolveStopOnFirstFailure(DataClass $dataClass): bool return $dataClass->stopOnFirstFailure; } - $result = $this->container->call( - "{$dataClass->name}::stopOnFirstFailure", - ); + $result = $this->container->call([$dataClass->name, 'stopOnFirstFailure']); if (! is_bool($result)) { throw new TypeError(sprintf( @@ -337,9 +347,7 @@ protected function resolveStringLifecycleSetting( return $attributeValue; } - $result = $this->container->call( - "{$dataClass->name}::{$method}", - ); + $result = $this->container->call([$dataClass->name, $method]); if (! is_string($result)) { throw new TypeError(sprintf( diff --git a/src/data/src/Support/Validation/EnsurePropertyMorphable.php b/src/data/src/Support/Validation/EnsurePropertyMorphable.php new file mode 100644 index 0000000000..eca9d8fa0c --- /dev/null +++ b/src/data/src/Support/Validation/EnsurePropertyMorphable.php @@ -0,0 +1,24 @@ + $rules + */ + public function __construct( + protected array $rules = [] + ) { + } + + /** + * Create a property rule set from the given rules. + */ + public static function create(ValidationRule ...$rules): self + { + return (new self)->add(...$rules); + } + + /** + * Append rules, replacing existing rules of the same type. + * + * @return $this + */ + public function add(ValidationRule ...$rules): static + { + $this->removeType(...$rules); + + array_push($this->rules, ...$rules); + + return $this; + } + + /** + * Prepend rules, replacing existing rules of the same type. + * + * @return $this + */ + public function prepend(ValidationRule ...$rules): static + { + $this->removeType(...$rules); + + array_unshift($this->rules, ...$rules); + + return $this; + } + + /** + * Remove rules of the given types. + * + * A requiring rule instance removes every requiring rule; a class name removes only rules of that class. + * + * @param class-string|ValidationRule ...$classes + * @return $this + */ + public function removeType(string|ValidationRule ...$classes): static + { + foreach ($this->rules as $i => $rule) { + foreach ($classes as $class) { + if ($class instanceof RequiringRule && $rule instanceof RequiringRule) { + unset($this->rules[$i]); + + continue 2; + } + + if ($rule instanceof $class) { + unset($this->rules[$i]); + + continue 2; + } + } + } + + $this->rules = array_values($this->rules); + + return $this; + } + + /** + * Determine if a rule of the given type is present. + * + * @param class-string $class + */ + public function hasType(string $class): bool + { + foreach ($this->rules as $rule) { + if ($rule instanceof $class) { + return true; + } + } + + return false; + } + + /** + * Get every rule in order. + * + * @return list + */ + public function all(): array + { + return $this->rules; + } +} diff --git a/src/data/src/Support/Validation/References/AuthenticatedUserReference.php b/src/data/src/Support/Validation/References/AuthenticatedUserReference.php new file mode 100644 index 0000000000..8f3b098b1a --- /dev/null +++ b/src/data/src/Support/Validation/References/AuthenticatedUserReference.php @@ -0,0 +1,37 @@ +guard)->user(); + + if ($user === null) { + return null; + } + + if ($this->property === null) { + return $user; + } + + return data_get($user, $this->property); + } +} diff --git a/src/data/src/Support/Validation/References/ContainerReference.php b/src/data/src/Support/Validation/References/ContainerReference.php new file mode 100644 index 0000000000..10be024b16 --- /dev/null +++ b/src/data/src/Support/Validation/References/ContainerReference.php @@ -0,0 +1,37 @@ + $parameters + */ + public function __construct( + public readonly string $dependency, + public readonly ?string $property = null, + public readonly array $parameters = [], + ) { + } + + /** + * Resolve the dependency, or the given property of it, from the container. + */ + public function getValue(): mixed + { + // An unresolvable binding is a configuration error, so it fails instead of becoming a null rule parameter. + $dependency = Container::getInstance()->make($this->dependency, $this->parameters); + + if ($this->property !== null) { + return data_get($dependency, $this->property); + } + + return $dependency; + } +} diff --git a/src/data/src/Support/Validation/References/RouteParameterReference.php b/src/data/src/Support/Validation/References/RouteParameterReference.php new file mode 100644 index 0000000000..a2b3761a8e --- /dev/null +++ b/src/data/src/Support/Validation/References/RouteParameterReference.php @@ -0,0 +1,50 @@ +route($this->routeParameter); + + if ($parameter === null && $this->nullable === false) { + throw CannotResolveRouteParameterReference::parameterNotFound($this->routeParameter, $this->property); + } + + if ($parameter === null) { + return null; + } + + if ($this->property === null) { + return $parameter; + } + + $value = data_get($parameter, $this->property); + + if ($value === null) { + throw CannotResolveRouteParameterReference::propertyOnParameterNotFound($this->routeParameter, $this->property); + } + + return $value; + } +} diff --git a/src/data/src/Support/Validation/RuleDenormalizer.php b/src/data/src/Support/Validation/RuleDenormalizer.php index 120dd3890c..76f9a2a7cc 100644 --- a/src/data/src/Support/Validation/RuleDenormalizer.php +++ b/src/data/src/Support/Validation/RuleDenormalizer.php @@ -5,6 +5,7 @@ namespace Hypervel\Data\Support\Validation; use BackedEnum; +use Closure; use DateTimeInterface; use Hypervel\Data\Attributes\Validation\CustomValidationAttribute; use Hypervel\Data\Attributes\Validation\ObjectValidationAttribute; @@ -19,26 +20,31 @@ class RuleDenormalizer /** * Convert one declaration into Validator rules. * + * @param null|Closure(FieldReference, ValidationPath): string $resolveField * @return list */ - public function execute(mixed $rule, ValidationPath $path): array + public function execute(mixed $rule, ValidationPath $path, ?Closure $resolveField = null): array { if (is_string($rule)) { - return str_contains($rule, 'regex:') ? [$rule] : explode('|', $rule); + // A regex may contain |, so a string starting with one is a single rule; other strings split like Laravel's. + // The names match Laravel's ValidationRuleParser::ruleIsRegex(). + return in_array(strtolower(explode(':', $rule, 2)[0]), ['regex', 'not_regex', 'notregex'], true) + ? [$rule] + : explode('|', $rule); } if (is_array($rule)) { $rules = []; foreach ($rule as $nestedRule) { - array_push($rules, ...$this->execute($nestedRule, $path)); + array_push($rules, ...$this->execute($nestedRule, $path, $resolveField)); } return $rules; } if ($rule instanceof StringValidationAttribute) { - return $this->normalizeStringValidationAttribute($rule, $path); + return $this->normalizeStringValidationAttribute($rule, $path, $resolveField); } if ($rule instanceof ObjectValidationAttribute) { @@ -52,7 +58,7 @@ public function execute(mixed $rule, ValidationPath $path): array } if ($rule instanceof Rule) { - return $this->execute($rule->get(), $path); + return $this->execute($rule->get(), $path, $resolveField); } return [$rule]; @@ -61,17 +67,19 @@ public function execute(mixed $rule, ValidationPath $path): array /** * Convert a string attribute into one Validator rule. * + * @param null|Closure(FieldReference, ValidationPath): string $resolveField * @return list */ protected function normalizeStringValidationAttribute( StringValidationAttribute $rule, ValidationPath $path, + ?Closure $resolveField, ): array { $parameters = []; $quoteParameters = ! in_array($rule->keyword(), ['regex', 'not_regex'], true); foreach ($rule->parameters() as $key => $value) { - $parameter = $this->normalizeRuleParameter($value, $path); + $parameter = $this->normalizeRuleParameter($value, $path, $resolveField); if ($parameter === null) { continue; @@ -99,11 +107,13 @@ protected function normalizeStringValidationAttribute( /** * Normalize one rule parameter while preserving its field boundaries. * + * @param null|Closure(FieldReference, ValidationPath): string $resolveField * @return null|list|string */ protected function normalizeRuleParameter( mixed $parameter, ValidationPath $path, + ?Closure $resolveField, ): array|string|null { if ($parameter === null) { return null; @@ -124,7 +134,7 @@ protected function normalizeRuleParameter( if (is_array($parameter)) { // ValidatesAttributes::convertValuesToNull() decodes list values from this literal token. $subParameters = array_map( - fn (mixed $subParameter): array|string => $this->normalizeRuleParameter($subParameter, $path) ?? 'null', + fn (mixed $subParameter): array|string => $this->normalizeRuleParameter($subParameter, $path, $resolveField) ?? 'null', $parameter ); @@ -140,11 +150,11 @@ protected function normalizeRuleParameter( } if ($parameter instanceof FieldReference) { - return $parameter->getValue($path); + return $resolveField === null ? $parameter->getValue($path) : $resolveField($parameter, $path); } if ($parameter instanceof ExternalReference) { - return $this->normalizeRuleParameter($parameter->getValue(), $path); + return $this->normalizeRuleParameter($parameter->getValue(), $path, $resolveField); } return (string) $parameter; diff --git a/src/data/src/Support/Validation/RuleNormalizer.php b/src/data/src/Support/Validation/RuleNormalizer.php new file mode 100644 index 0000000000..acf777e0e4 --- /dev/null +++ b/src/data/src/Support/Validation/RuleNormalizer.php @@ -0,0 +1,134 @@ + + */ + public function execute( + string|array|Closure|Stringable|CompilableRules|ConditionalRules|ValidationRule|RuleContract|ValidationRuleContract $rule + ): array { + if (is_array($rule)) { + return $this->resolveArrayRule($rule); + } + + if (is_string($rule)) { + return $this->resolveStringRule($rule); + } + + if ($rule instanceof Rule) { + return $this->execute($rule->get()); + } + + if ($rule instanceof ValidationRule) { + return [$rule]; + } + + $objectRule = match (true) { + $rule instanceof DimensionsRule => new Dimensions(rule: $rule), + $rule instanceof EnumRule => new Enum($rule), + $rule instanceof ExcludeIfRule => new Exclude($rule), + $rule instanceof ExistsRule => new Exists(rule: $rule), + $rule instanceof InRule => new In($rule), + $rule instanceof NotInRule => new NotIn($rule), + $rule instanceof PasswordRule => new Password(rule: $rule), + $rule instanceof ProhibitedIfRule => new Prohibited($rule), + $rule instanceof RequiredIfRule => new Required($rule), + $rule instanceof UniqueRule => new Unique(rule: $rule), + default => null, + }; + + if ($objectRule !== null) { + return [$objectRule]; + } + + // Closures, custom and fluent rules, and conditional or nested rules stay as given for the validator. + return [new Rule($rule)]; + } + + /** + * Normalize every rule in an array. + * + * @return list + */ + protected function resolveArrayRule(array $rules): array + { + return Arr::flatten(array_map( + fn (mixed $rule): array => $this->execute($rule), + $rules + )); + } + + /** + * Normalize a rule string, keeping any rule no attribute can hold as written. + * + * @return list + */ + protected function resolveStringRule(string $rule): array + { + $rules = []; + + // A regex may contain |, so a string starting with one is a single rule; other strings split like Laravel's. + // The names match Laravel's ValidationRuleParser::ruleIsRegex(). + $subRules = in_array(strtolower(explode(':', $rule, 2)[0]), ['regex', 'not_regex', 'notregex'], true) + ? [$rule] + : explode('|', $rule); + foreach ($subRules as $subRule) { + try { + $rules[] = $this->ruleFactory->create($subRule); + } catch (CouldNotCreateValidationRule|CannotBuildValidationRule|TypeError|ValueError) { + $rules[] = new Rule($subRule); + } + } + + return $rules; + } +} diff --git a/src/data/src/Support/Validation/ValidationAccumulator.php b/src/data/src/Support/Validation/ValidationAccumulator.php index f42cd65fa9..bfd5fe5025 100644 --- a/src/data/src/Support/Validation/ValidationAccumulator.php +++ b/src/data/src/Support/Validation/ValidationAccumulator.php @@ -4,6 +4,7 @@ namespace Hypervel\Data\Support\Validation; +use Hypervel\Validation\Rules\Enum; use Hypervel\Validation\Rules\Exists; use Hypervel\Validation\Rules\Unique; use Hypervel\Validation\ValidationRuleParser; @@ -25,6 +26,13 @@ class ValidationAccumulator /** @var list */ public array $preservedPaths = []; + /** + * Constructor inputs that are preserved unless a compiled rule governs them. + * + * @var list + */ + public array $constructorInputPaths = []; + /** @var list */ public array $additionalFields = []; @@ -58,7 +66,16 @@ public function equals(self $other): bool } } + foreach ($this->constructorInputPaths as $index => $path) { + if (! isset($other->constructorInputPaths[$index]) + || $path->get() !== $other->constructorInputPaths[$index]->get() + ) { + return false; + } + } + return count($this->preservedPaths) === count($other->preservedPaths) + && count($this->constructorInputPaths) === count($other->constructorInputPaths) && $this->additionalFields === $other->additionalFields && $this->allowedSubtrees === $other->allowedSubtrees && $this->finishedStructuralPaths === $other->finishedStructuralPaths @@ -78,6 +95,7 @@ public function merge(self $other): void $this->messages = array_replace($this->messages, $other->messages); $this->attributes = array_replace($this->attributes, $other->attributes); array_push($this->preservedPaths, ...$other->preservedPaths); + array_push($this->constructorInputPaths, ...$other->constructorInputPaths); array_push($this->additionalFields, ...$other->additionalFields); array_push($this->allowedSubtrees, ...$other->allowedSubtrees); $this->finishedStructuralPaths += $other->finishedStructuralPaths; @@ -129,6 +147,11 @@ protected function ruleValueEquals(mixed $rule, mixed $otherRule): bool && $this->rulesEqual($rule, $otherRule); } + // Each item compiles its own inferred enum rule, so equal rules compare by their type and case restrictions. + if ($rule instanceof Enum) { + return $otherRule instanceof Enum && $rule == $otherRule; + } + if (($rule instanceof Exists || $rule instanceof Unique) && $rule->queryCallbacks() !== [] ) { diff --git a/src/data/src/Support/Validation/ValidationRuleFactory.php b/src/data/src/Support/Validation/ValidationRuleFactory.php new file mode 100644 index 0000000000..6d3584ed70 --- /dev/null +++ b/src/data/src/Support/Validation/ValidationRuleFactory.php @@ -0,0 +1,281 @@ +> + */ + protected const array MAPPING = [ + 'accepted' => Accepted::class, + 'accepted_if' => AcceptedIf::class, + 'active_url' => ActiveUrl::class, + 'after' => After::class, + 'after_or_equal' => AfterOrEqual::class, + 'alpha' => Alpha::class, + 'alpha_dash' => AlphaDash::class, + 'alpha_num' => AlphaNumeric::class, + 'any_of' => AnyOf::class, + 'array' => ArrayType::class, + 'ascii' => Ascii::class, + 'bail' => Bail::class, + 'base64' => Base64::class, + 'before' => Before::class, + 'before_or_equal' => BeforeOrEqual::class, + 'between' => Between::class, + 'boolean' => BooleanType::class, + 'can' => Can::class, + 'confirmed' => Confirmed::class, + 'contains' => Contains::class, + 'current_password' => CurrentPassword::class, + 'date' => Date::class, + 'date_equals' => DateEquals::class, + 'date_format' => DateFormat::class, + 'decimal' => Decimal::class, + 'declined' => Declined::class, + 'declined_if' => DeclinedIf::class, + 'different' => Different::class, + 'digits' => Digits::class, + 'digits_between' => DigitsBetween::class, + 'dimensions' => Dimensions::class, + 'distinct' => Distinct::class, + 'doesnt_contain' => DoesntContain::class, + 'doesnt_end_with' => DoesntEndWith::class, + 'doesnt_start_with' => DoesntStartWith::class, + 'email' => Email::class, + 'encoding' => Encoding::class, + 'ends_with' => EndsWith::class, + 'enum' => Enum::class, + 'exclude' => Exclude::class, + 'exclude_if' => ExcludeIf::class, + 'exclude_unless' => ExcludeUnless::class, + 'exclude_with' => ExcludeWith::class, + 'exclude_without' => ExcludeWithout::class, + 'exists' => Exists::class, + 'extensions' => Extensions::class, + 'file' => File::class, + 'filled' => Filled::class, + 'gt' => GreaterThan::class, + 'gte' => GreaterThanOrEqualTo::class, + 'hex_color' => HexColor::class, + 'image' => Image::class, + 'in' => In::class, + 'in_array' => InArray::class, + 'in_array_keys' => InArrayKeys::class, + 'integer' => IntegerType::class, + 'ip' => IP::class, + 'ipv4' => IPv4::class, + 'ipv6' => IPv6::class, + 'json' => Json::class, + 'lt' => LessThan::class, + 'lte' => LessThanOrEqualTo::class, + 'list' => ListType::class, + 'lowercase' => Lowercase::class, + 'mac_address' => MacAddress::class, + 'max' => Max::class, + 'max_digits' => MaxDigits::class, + 'mimes' => Mimes::class, + 'mimetypes' => MimeTypes::class, + 'min' => Min::class, + 'min_digits' => MinDigits::class, + 'missing' => Missing::class, + 'missing_if' => MissingIf::class, + 'missing_unless' => MissingUnless::class, + 'missing_with' => MissingWith::class, + 'missing_with_all' => MissingWithAll::class, + 'multiple_of' => MultipleOf::class, + 'not_in' => NotIn::class, + 'not_regex' => NotRegex::class, + 'nullable' => Nullable::class, + 'numeric' => Numeric::class, + 'password' => Password::class, + 'present' => Present::class, + 'present_if' => PresentIf::class, + 'present_unless' => PresentUnless::class, + 'present_with' => PresentWith::class, + 'present_with_all' => PresentWithAll::class, + 'prohibited' => Prohibited::class, + 'prohibited_if' => ProhibitedIf::class, + 'prohibited_if_accepted' => ProhibitedIfAccepted::class, + 'prohibited_if_declined' => ProhibitedIfDeclined::class, + 'prohibited_unless' => ProhibitedUnless::class, + 'prohibits' => Prohibits::class, + 'regex' => Regex::class, + 'required' => Required::class, + 'required_array_keys' => RequiredArrayKeys::class, + 'required_if' => RequiredIf::class, + 'required_if_accepted' => RequiredIfAccepted::class, + 'required_if_declined' => RequiredIfDeclined::class, + 'required_unless' => RequiredUnless::class, + 'required_with' => RequiredWith::class, + 'required_with_all' => RequiredWithAll::class, + 'required_without' => RequiredWithout::class, + 'required_without_all' => RequiredWithoutAll::class, + 'same' => Same::class, + 'size' => Size::class, + 'sometimes' => Sometimes::class, + 'starts_with' => StartsWith::class, + 'string' => StringType::class, + 'timezone' => Timezone::class, + 'ulid' => Ulid::class, + 'unique' => Unique::class, + 'uppercase' => Uppercase::class, + 'url' => Url::class, + 'uuid' => Uuid::class, + ]; + + /** + * Create the validation attribute for a Validator rule string. + * + * @throws CouldNotCreateValidationRule when no attribute has the rule's keyword + */ + public function create(string $rule): ValidationRule + { + [$keyword, $parameters] = ValidationRuleParser::parse($rule); + + $ruleClass = $this->mapping()[Str::snake($keyword)] ?? null; + + if ($ruleClass === null) { + throw CouldNotCreateValidationRule::create($rule); + } + + return $ruleClass::create(...$parameters); + } + + /** + * Get the attribute class for each rule keyword. + * + * @return array> + */ + protected function mapping(): array + { + return self::MAPPING; + } +} diff --git a/src/data/src/Support/Wrapping/WrapExecutionType.php b/src/data/src/Support/Wrapping/WrapExecutionType.php index 4f19ec6667..b9e3c74595 100644 --- a/src/data/src/Support/Wrapping/WrapExecutionType.php +++ b/src/data/src/Support/Wrapping/WrapExecutionType.php @@ -4,11 +4,11 @@ namespace Hypervel\Data\Support\Wrapping; -enum WrapExecutionType +enum WrapExecutionType: string { - case Disabled; - case Enabled; - case TemporarilyDisabled; + case Disabled = 'Disabled'; + case Enabled = 'Enabled'; + case TemporarilyDisabled = 'TemporarilyDisabled'; /** * Determine if wrapping should run at the current node. diff --git a/src/data/src/Transformers/SerializeTransformer.php b/src/data/src/Transformers/SerializeTransformer.php new file mode 100644 index 0000000000..2aee9592ce --- /dev/null +++ b/src/data/src/Transformers/SerializeTransformer.php @@ -0,0 +1,19 @@ +app->resolveContextualParameters(ReportFilter::class); + +$parameters['status'] = ReportStatus::from($parameters['status']); + +$filter = $this->app->buildWith(ReportFilter::class, $parameters); +``` + +Pass a list of parameter names as the second argument to resolve only those parameters. The container resolves any others when the class is built: + +```php +$parameters = $this->app->resolveContextualParameters(ReportFilter::class, ['status']); +``` + ### Transient Classes diff --git a/src/docs/data-objects.md b/src/docs/data-objects.md index c260a65c05..1fbb3e7768 100644 --- a/src/docs/data-objects.md +++ b/src/docs/data-objects.md @@ -1,35 +1,55 @@ # Data Objects - [Introduction](#introduction) -- [Choosing a Base Class](#choosing-a-base-class) -- [Lightweight Data Objects](#lightweight-data-objects) +- [Getting Started](#getting-started) + - [Generating Data Classes](#generating-data-classes) + - [Choosing a Base Class](#choosing-a-base-class) - [Creating Data Objects](#creating-data-objects) - [Creating Instances](#creating-instances) + - [Creating From Requests](#creating-from-requests) + - [Creating From Models](#creating-from-models) - [Associating a Data Class](#associating-a-data-class) - [Defaults, Null, and Optional Values](#defaults-null-and-optional-values) + - [Empty Representations](#empty-representations) + - [Constructor Properties](#constructor-properties) - [Named Factories](#named-factories) + - [Preparing Input](#preparing-input) - [Property Name Conversion](#property-name-conversion) + - [Creation Factories](#creation-factories) - [Type Conversion](#type-conversion) - [Date and Time Values](#date-and-time-values) - [Nested Data Objects](#nested-data-objects) + - [Abstract Data Objects](#abstract-data-objects) - [Backed Enums](#backed-enums) - [Casts and Transformers](#casts-and-transformers) + - [Custom Casts](#custom-casts) + - [Custom Transformers](#custom-transformers) + - [Global Casts and Transformers](#global-casts-and-transformers) +- [Normalizers](#normalizers) - [Validation](#validation) + - [Inferred Rules](#inferred-rules) - [Validation Attributes](#validation-attributes) - [Manual Rules and Hooks](#manual-rules-and-hooks) - - [Creation Factories](#creation-factories) + - [Authorizing Requests](#authorizing-requests) + - [Customizing the Validator](#customizing-the-validator) - [Transformation](#transformation) - [Lazy Properties](#lazy-properties) - - [Partial Trees](#partial-trees) + - [Including and Excluding Properties](#including-and-excluding-properties) - [Hidden, Computed, and Appended Values](#hidden-computed-and-appended-values) - [Collections](#collections) - [HTTP Resources](#http-resources) + - [Wrapping](#wrapping) + - [Paginated Responses](#paginated-responses) + - [Customizing Responses](#customizing-responses) + - [Selecting Properties From Requests](#selecting-properties-from-requests) - [Form Request Casting](#form-request-casting) - [Eloquent Casting](#eloquent-casting) + - [Defaults and Encryption](#eloquent-defaults-and-encryption) + - [Abstract Classes](#eloquent-abstract-classes) - [Contextual Constructor Values](#contextual-constructor-values) - [Inertia](#inertia) - [Saloon](#saloon) -- [Generating Data Classes](#generating-data-classes) +- [Lightweight Data Objects](#lightweight-data-objects) - [Worker Lifetime](#worker-lifetime) - [Credits](#credits) @@ -40,169 +60,198 @@ Hypervel Data provides an expressive way to turn arrays, requests, Eloquent mode If you have used Spatie Laravel Data, the package's classes and methods should feel familiar. Hypervel Data provides this familiar API while remaining suitable for Hypervel's long-running workers. - -## Choosing a Base Class - -The package provides three base classes: - -- `Data` supports creation, validation, transformation, HTTP responses, collections, and Eloquent casting. -- `Dto` supports creation and validation without transformation or response behavior. It is a good choice for commands, service boundaries, and domain input. -- `Resource` supports creation, transformation, HTTP responses, collections, and Eloquent casting without the public validation methods. - -Choose the 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. + +## Getting Started -To define a lightweight data object, extend `DataObject` and promote every constructor parameter as a public property: +To define a data object, extend `Data` and declare its public properties: ```php - 'msg_01', - 'type' => 'created', - 'payload' => ['name' => 'Taylor'], - 'receivedAt' => '2026-09-05 12:34:56', +$user = UserData::from([ + 'name' => 'Taylor Otwell', + 'age' => '39', + 'email' => 'taylor@example.com', ]); + +$user->age; // 39 +$user->toArray(); ``` -In this example, `MessageType` is a string-backed enum and `MessagePayload` is another lightweight data object. +Data objects can also [validate incoming requests](#creating-from-requests), [represent Eloquent models](#creating-from-models), and be [returned directly from controllers](#http-resources). -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`. + +### Generating Data Classes -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. +Generate a class with the `make:data` command: -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. +```shell +php artisan make:data User +``` -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: +By default, this creates `App\Data\UserData`. A name that already ends with `Data` is left unchanged. You may change these defaults using the `data.commands.make.namespace` and `data.commands.make.suffix` configuration options. Set the suffix to an empty string to disable it. -```php -protected function casts(): array -{ - return [ - 'contact' => Contact::class, - 'contacts.*' => Contact::class, - ]; -} +For a single class, pass `--suffix` or `--target-namespace`: + +```shell +php artisan make:data Post --suffix=Dto --target-namespace="App\DataTransferObjects" ``` -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. +Like Hypervel's other generators, the command honors an application stub override and supports `--force`. To customize the package's configuration, publish its configuration file: -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`. +```shell +php artisan vendor:publish --tag=data-config +``` -An `array` property retains its input items as-is during construction. Convert a one-off list explicitly: + +### Choosing a Base Class -```php -$items = array_map(ItemData::from(...), $payload['items']); -``` +The package provides three base classes: -Use Hypervel Data and `DataCollection` when a reusable typed collection needs validation, mapping, transformation controls, or response behavior. +- `Data` supports creation, validation, transformation, HTTP responses, collections, and Eloquent casting. +- `Dto` supports creation and validation without transformation or response behavior. It is a good choice for commands, service boundaries, and domain input. +- `Resource` supports creation, transformation, HTTP responses, collections, and Eloquent casting without the public validation methods. + +Choose the 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). ## Creating Data Objects -Extend one of the base classes and define the object's public properties: +Data objects are ordinary PHP objects, so you may construct one directly when you already have values of the correct types: ```php - +### Creating Instances -namespace App\Data; +Create an object with `from`: -use Hypervel\Data\Data; +```php +$user = UserData::from([ + 'name' => 'Taylor Otwell', + 'age' => '39', + 'email' => 'taylor@example.com', +]); +``` -class UserData extends Data -{ - public function __construct( - public string $name, - public int $age, - public string $email, - public ?string $phone = null, - ) { - } -} +`from` accepts arrays, JSON strings, `Arrayable` objects, initialized public properties from ordinary objects, Eloquent models, and requests. Existing instances of the requested data type pass through unchanged. + +A model attribute holding `null` is passed as `null`. Columns that were not selected, and attributes the model refuses to read, are treated as missing, so the property's default, `Optional`, or `null` applies. + +You may pass multiple payloads. Later payloads take precedence: for each property, the last payload containing its input key wins, including when that value is `null`. An `Optional` value never replaces a value supplied by an earlier payload: + +```php +$user = UserData::from($defaults, $requestValues, $routeValues); ``` -Constructor-promoted `readonly` properties are supported. Public properties that are not declared by the constructor are assigned after construction and therefore cannot be `readonly`, unless the class sets the value itself as a `#[Computed]` property. +Payloads may also be passed as PHP named arguments. Hypervel preserves their names while selecting a named factory. When no factory matches, the payload values are processed in call order, so the same precedence applies. + +Use `optional` when the whole object may be absent. It returns `null` when no payload is supplied or every supplied payload is `null`: ```php - +### Creating From Requests -namespace App\Data; +You may type-hint a data class in a controller method. Hypervel creates it from the current request and validates the input before calling your method: -use Hypervel\Data\Dto; +```php +use App\Data\UserData; +use App\Models\User; +use Hypervel\Http\RedirectResponse; -class UserCommand extends Dto +public function store(UserData $data): RedirectResponse { - public function __construct( - public readonly string $name, - public readonly int $age, - public readonly string $email, - ) { - } + User::create($data->toArray()); + + return redirect()->route('users.index'); } ``` - -### Creating Instances +The property's type supplies its basic validation rules. Add [validation attributes or manual rules](#validation) for requirements such as a valid email address. If validation fails, Hypervel returns the usual validation response instead of calling the controller. -Create an object with `from`: +You may also pass a request explicitly: ```php -$user = UserData::from([ - 'name' => 'Taylor Otwell', - 'age' => '39', - 'email' => 'taylor@example.com', -]); +$data = UserData::from($request); ``` -`from` accepts arrays, JSON strings, `Arrayable` objects, initialized public properties from ordinary objects, Eloquent models, and requests. Existing instances of the requested data type pass through unchanged. +Under the default validation strategy, passing `$request->all()` instead passes an array and does not trigger validation. Use `validateAndCreate()` when an array needs validation. -You may pass multiple payloads. For each property, the first payload containing its input key wins, including when that value is `null`: + +### Creating From Models + +Pass an Eloquent model to `from` to read its attributes, including model casts and accessors: ```php -$user = UserData::from($routeValues, $requestValues, $defaults); +$data = UserData::from(User::findOrFail($id)); ``` -Payloads may also be passed as PHP named arguments. Hypervel preserves their names while selecting a named factory. When no factory matches, the payload values are processed in call order, so the same first-payload-wins precedence applies. +By default, camel-case properties read the matching snake-case model attributes, so `createdAt` reads `created_at` without a mapper. -Use `optional` when the whole object may be absent. It returns `null` when no payload is supplied or every supplied payload is `null`: +Loaded relationships can become nested data objects or typed collections. For example, an album with its songs may be represented as follows: ```php -$user = UserData::optional($payload); +use Hypervel\Support\Collection; + +class SongData extends Data +{ + public function __construct(public string $title) + { + } +} + +class AlbumData extends Data +{ + /** + * @param Collection $songs + */ + public function __construct( + public string $title, + public Collection $songs, + ) { + } +} + +$album = AlbumData::from(Album::with('songs')->findOrFail($id)); ``` +Unloaded relationships are treated as missing. Eager-load the relationships you need, or mark a property with `#[LoadRelation]` to load it automatically: + +```php +use Hypervel\Data\Attributes\LoadRelation; + +#[LoadRelation] +public ArtistData $artist; +``` + +When collecting models, Hypervel loads the requested relationships together. For relationships that should appear only when already loaded, use [relation-aware lazy properties](#lazy-properties). + +Avoid automatic loading on both sides of a circular relationship, such as an artist's songs and each song's artist, since creation would keep loading and constructing the same relationships. + ### Associating a Data Class @@ -226,12 +275,12 @@ $data = $user->getData(); You may instead return the class from a `dataClass()` method. The `$dataClass` property takes precedence when both are declared. -When a FormRequest uses `WithData`, `getData()` runs the associated data class's authorization and validation rules. It does not reuse the FormRequest's rules. Pass `$request->validated()` directly to `UserData::from()` when you want to construct from the FormRequest's validated result instead. +When a FormRequest uses `WithData`, `getData()` runs the associated data class's authorization and validation rules. It does not reuse the FormRequest's rules. To construct from the FormRequest's validated result instead, pass `$request->validated()` directly to `UserData::from()` or enable the [`FormRequestNormalizer`](#normalizers). ### Defaults, Null, and Optional Values -Missing properties resolve in this order: a declared constructor default, an `Optional` union, then `null` for a nullable type. Any other missing property fails validation or construction. +For constructor properties, missing input uses the declared default, then `Optional`, then `null` if the type allows it. Any other missing property fails validation or construction. ```php use Hypervel\Data\Optional; @@ -249,6 +298,34 @@ class PatchUserData extends Data For this object, an omitted `name` becomes `Optional::create()`, an omitted `phone` becomes `null`, and an omitted `locale` uses `en`. Explicit `null` is a supplied value and is accepted only when the declared type allows it. Use `#[Present]` when a nullable input key must still be supplied. +`Optional` values are omitted from transformed output. This is useful for a PATCH request, where an absent value means the existing value should remain unchanged: + +```php +PatchUserData::from(['phone' => null])->toArray(); + +// ['phone' => null, 'locale' => 'en'] +``` + +Call `factory()->withoutOptionalValues()` when missing nullable properties should receive `null` instead of `Optional`. A missing `string|null|Optional` property then receives `null`, while a missing `string|Optional` property keeps `Optional` because it cannot hold `null`. + +A property declared outside the constructor keeps a constructor-assigned value when its input is missing or `Optional`. This allows defaults that need to be calculated: + +```php +use Hypervel\Support\CarbonImmutable; + +class PublishedData extends Data +{ + public CarbonImmutable|Optional $publishedAt; + + public function __construct() + { + $this->publishedAt = CarbonImmutable::now(); + } +} +``` + +Supplied input, including `null`, replaces that value. Validation infers rules from the declaration alone, so when requests may omit such a property, give it a nullable or `Optional` type or add `#[Sometimes]`. + ### Empty Representations @@ -270,9 +347,7 @@ UserData::empty(); // ['name' => null, 'roles' => [], 'active' => true] ``` -You may pass replacement values as the first argument, change the value used for otherwise empty properties with `replaceNullValuesWith`, or filter the result using `only` and `except`: - -Replacement values are keyed by PHP property name and apply only to properties without a declared default. The `only` and `except` lists use the mapped output names in the empty representation. +You may pass replacement values as the first argument, change the value used for otherwise empty properties with `replaceNullValuesWith`, or filter the result using `only` and `except`. Replacement values use PHP property names and apply only to properties without a declared default. The filters use output names: ```php UserData::empty( @@ -281,6 +356,48 @@ UserData::empty( ); ``` + +### Constructor Properties + +Constructor-promoted `readonly` properties are supported, including properties promoted by a parent constructor that your constructor calls: + +```php +use Hypervel\Data\Dto; + +class UserCommand extends Dto +{ + public function __construct( + public readonly string $name, + public readonly int $age, + public readonly string $email, + ) { + } +} +``` + +Public properties declared outside the constructor are assigned after construction. They cannot be `readonly`, unless the class sets the value itself as a `#[Computed]` property. + +A plain constructor parameter without a matching public property receives input with the same name, unchanged. Name mapping, casts, and inferred rules do not apply to it, but explicit `rules()` and creation hooks do. Eloquent models do not supply these values: + +```php +use Hypervel\Data\Attributes\Computed; + +class SearchData extends Data +{ + #[Computed] + public string $term; + + public function __construct(string $query) + { + $this->term = trim($query); + } +} + +SearchData::from(['query' => ' hypervel ']); +``` + +Input never sets a `protected` or `private` promoted property. It keeps its default, and creation fails when it has none, unless a `beforeCreation` hook or named factory supplies it. + ### Named Factories @@ -304,24 +421,65 @@ class UserData extends Data } ``` -Named methods may receive dependencies from the service container as well as a `CreationContext`. When a named method returns the requested data object, Hypervel uses it directly without validating, casting, or running creation hooks for it again. If the method returns another supported input value, Hypervel creates the object from that value without calling another named factory. +Named methods may receive dependencies from the service container as well as a `CreationContext`. A named method must return the requested data object. Hypervel uses it directly without validating, casting, or running creation hooks for it again. + +> [!WARNING] +> A named method that accepts a request is responsible for validating it. The data class's authorization still runs first. + +To reshape input while keeping ordinary validation and creation, use a [normalizer](#normalizers) or [`prepareForPipeline()`](#preparing-input). Beneath a property with an explicit cast, a named method runs only if the cast declines the value. When the object is validated, validation checks the value against the declared rules first. During ordinary creation, Hypervel maps each input value to a public property. It cannot determine how one property should be divided among variadic constructor arguments. A private or protected constructor is also unavailable to ordinary creation. In either case, use a named factory that returns the finished object. You may also define public static methods beginning with `collect` to customize collection creation. These methods receive the source's own array, collection, or paginator shape after its values have been converted to data objects, rather than the original source values. An Eloquent collection source is provided as a base `Hypervel\Support\Collection`. When you request an explicit collection target, the method's declared return type must also match that target. - -### Property Name Conversion + +### Preparing Input -Input and output names are unchanged by default. Use mapping attributes when the wire format differs from the PHP property name: +Define a static `prepareForPipeline` method when a class needs to reshape its input before its properties are read: ```php - 'Never Gonna Give You Up', + 'year' => '1987', + 'producer' => 'Stock Aitken Waterman', +]); +``` + +The method receives each payload separately. When a factory has `prepareData` hooks, they run first, and the method receives their merged result once. An Eloquent model is passed as an array of its declared property values. For a property-morphable class, the method of the selected class is called. A named factory that returns the finished object skips this method. + + +### Property Name Conversion +Input and output names are unchanged by default. Use mapping attributes when an input or output key differs from the PHP property name: + +```php use Hypervel\Data\Attributes\MapInputName; use Hypervel\Data\Attributes\MapOutputName; use Hypervel\Data\Data; @@ -353,12 +511,107 @@ $product->productName; `MapName` applies the same name in both directions. `MapInputName` and `MapOutputName` keep the directions independent. Class-level mappers such as `SnakeCaseMapper`, `CamelCaseMapper`, and `KebabCaseMapper` provide a convention for every property, while a property attribute overrides the class mapper. -Mapped input paths may use dot notation. When both the mapped input path and PHP property name are present, the mapped input wins. Hypervel rejects a data class when two properties use the same input path or output key instead of silently overwriting a value. +```php +use Hypervel\Data\Attributes\MapName; +use Hypervel\Data\Mappers\SnakeCaseMapper; + +#[MapName(SnakeCaseMapper::class)] +class ProductData extends Data +{ + public function __construct(public string $productName) + { + } +} + +ProductData::from(['product_name' => 'Desk'])->toArray(); + +// ['product_name' => 'Desk'] +``` + +Set application-wide defaults in `config/data.php`: + +```php +'name_mapping_strategy' => [ + 'input' => SnakeCaseMapper::class, + 'output' => SnakeCaseMapper::class, +], +``` + +To read a nested input value, use a dot-separated path, such as `#[MapInputName('artist.name')]` or `#[MapInputName('artists.0.name')]`. + +Validated input must use the mapped path, which is where its rules apply. Without validation, the PHP property name is read when the mapped input is missing. Hypervel rejects conflicting input paths or output keys instead of silently overwriting a value. + + +### Creation Factories + +Use `factory()` to customize a single creation. For example, you may validate an import or disable named factories: + +```php +$user = UserData::factory()->alwaysValidate()->from($payload); + +$user = UserData::factory()->withoutMagicalCreation()->from($payload); +``` + +Other options include `withoutValidation()`, `onlyValidateRequests()`, `withoutPropertyNameMapping()`, `ignoreMagicalMethod('fromModel')`, and [`withoutOptionalValues()`](#defaults-null-and-optional-values). Use `withCast(Money::class, MoneyCast::class)` or `withNormalizers(CustomNormalizer::class)` to customize conversion for this creation. + +Within one operation, you may reuse a factory to avoid repeating setup: + +```php +$factory = UserData::factory(); + +$users = array_map( + fn (array $payload): UserData => $factory->from($payload), + $payloads, +); +``` + +Use `collect()` instead when the payloads form one collection. It preserves supported collection shapes and keys, and allows validation rules and hooks to inspect the complete payload. + +#### Creation Hooks + +Factories also let you prepare input and customize validation or construction: + +```php +$user = UserData::factory() + ->alwaysValidate() + ->prepareData(fn (array $data): array => [ + ...$data, + 'name' => trim($data['name']), + ]) + ->from($payload); +``` + +The available hooks run in the following order. Each receives the arguments shown and returns the replacement value, except `withValidator`, which customizes the validator directly: + +| Hook | Arguments | Return value | +| --- | --- | --- | +| `prepareData` | Input array | Input array | +| `beforeValidation` | Complete input array | Input array | +| `beforeRules` | `DataProperty`, `ValidationPath`, value | Rules, or `null` for ordinary inference | +| `afterRules` | Rules, `DataProperty`, `ValidationPath`, value | Rules | +| `withValidator` | `Validator` | None | +| `afterValidation` | Validated input array | Input array | +| `beforeCreation` | Cast property values | Property values | +| `afterCreation` | Created data object | Data object | + +The `prepareData`, `beforeCreation`, and `afterCreation` hooks run even when validation is skipped. The other hooks run while generating rules or validating, as appropriate. Call `alwaysValidate()` when validation hooks should also apply to non-request input. + +`beforeValidation` receives the complete input and may add fields for rules to read; `afterValidation` receives the validated payload. The values these hooks return are treated as prepared input, so normalizers, `prepareForPipeline()`, and `prepareData` hooks do not run on them again. `beforeCreation` receives the cast values that will be passed to the constructor or assigned after it. Properties declared outside the constructor whose input is missing are not included, so the hook may supply them; otherwise any value the constructor assigns is kept. + +Each call to `factory()` returns a new factory. Keep a reused factory scoped to the current operation instead of storing it across requests. + +A cast or named method may pass its `CreationContext` to another class's factory: + +```php +return AddressData::factory($context)->from($value); +``` + +The validation strategy, name mapping, named-method settings, casts, and normalizers are copied. Hooks are not, because they belong to the original creation. ## Type Conversion -`from` casts supported scalar values, backed enums, dates, nested data objects, and typed iterables to their declared PHP types. Values that already have the declared type are kept as-is. +`from` casts supported scalar values, backed enums, dates, nested data objects, and typed iterables to their declared PHP types. Values that already have the declared type are kept as-is. Scalars, including typed iterable items, follow PHP's own weak typing: `'42'` becomes `42`, while a value PHP cannot convert, such as `'abc'` for an `int` or an array for a `string`, fails with PHP's `TypeError`. The strings `'true'` and `'false'` become booleans, and a scalar becomes a one-item array. ```php class ProductData extends Data @@ -378,24 +631,51 @@ $product = ProductData::from([ ]); ``` -Ambiguous unions of data classes or typed data containers are not guessed. Supply an existing compatible value, define an explicit cast, or return the complete data object from a typed named factory. +A value that a union property already accepts is kept: a string given to `string|SongData` stays a string, and an array given to `array|Collection` stays an array. Declare `Collection` alone when the property should always hold a collection; an array given to it becomes one. Explicit casts still run first. + +When a union declares several containers, the value's own container selects the declared type, and that type's item declaration still applies: + +```php +use Hypervel\Support\Collection; + +class PlaylistData extends Data +{ + /** + * @param array|Collection $songs + */ + public function __construct( + public array|Collection $songs, + ) { + } +} + +PlaylistData::from(['songs' => ['1', '2']])->songs; + +// [1, 2] + +PlaylistData::from(['songs' => collect([['title' => 'Never Gonna Give You Up']])])->songs; + +// A Collection of SongData objects +``` + +Validation uses the rules of the selected type, so a string given to `string|SongData` is validated as a string. Hypervel does not guess between union types. With several declared data classes, or with several declared containers when more than one of them accepts the value or no declared type does, supply an existing compatible value, define an explicit cast, or return the complete data object from a typed named factory. ### Date and Time Values Date interfaces use Hypervel's configured Date factory. If a property declares a concrete date class, Hypervel creates an instance of that exact class. -Input is parsed using the `data.date_format` configuration option, which accepts one format or an ordered list of formats. The `data.date_timezone` option converts parsed and transformed dates to the configured timezone. To use a different source timezone for one property, pass `timeZone` to `DateTimeInterfaceCast`. Its `setTimeZone` argument may be used to override the configured target timezone for that property. - -Dates are transformed using the configured output format unless a property transformer overrides it: +Input is parsed using `data.date_format`, which defaults to `DATE_ATOM`. Set an ordered list to accept several formats; the first is also used for output: ```php - [DATE_ATOM, 'Y-m-d'], +``` -declare(strict_types=1); +The `data.date_timezone` option converts parsed and transformed dates to the configured timezone. To use a different source timezone for one property, pass `timeZone` to `DateTimeInterfaceCast`. Its `setTimeZone` argument overrides the configured target timezone for that property. -namespace App\Data; +For example, the default `DATE_ATOM` format accepts a date with its timezone: +```php use DateTimeInterface; use Hypervel\Data\Data; @@ -412,24 +692,31 @@ class EventData extends Data ```php $event = EventData::from([ 'title' => 'Conference', - 'startsAt' => '2026-04-30 09:00:00', + 'startsAt' => '2026-04-30T09:00:00+00:00', ]); ``` -For one property, select a different parser with `WithCast`: +Use `WithCast` and `WithTransformer` to choose a property's input and output formats: ```php use Hypervel\Data\Attributes\WithCast; +use Hypervel\Data\Attributes\WithTransformer; use Hypervel\Data\Casts\DateTimeInterfaceCast; +use Hypervel\Data\Transformers\DateTimeInterfaceTransformer; class EventData extends Data { public function __construct( #[WithCast(DateTimeInterfaceCast::class, format: 'Y-m-d')] + #[WithTransformer(DateTimeInterfaceTransformer::class, format: 'd/m/Y')] public DateTimeInterface $startsAt, ) { } } + +EventData::from(['startsAt' => '2026-04-30'])->toArray(); + +// ['startsAt' => '30/04/2026'] ``` @@ -438,14 +725,6 @@ class EventData extends Data Nested data objects are created recursively: ```php - -### Backed Enums +An item type may mix a data class with other types, such as `list`, when you construct the object yourself. Its items transform by value, so strings stay strings and data objects become arrays. Creating such an object from input with `from` is not supported: every item that is not already a data object is created as the data class, so a string item fails. -Backed enums are resolved from their backing values: +Without an item type, a data collection property can still hold a collection you created yourself, and its items transform by value. Since the item class is unknown, `from` cannot create that property from an array. + + +### Abstract Data Objects + +An abstract data class may choose a concrete subclass from its input. Implement `PropertyMorphableData` and mark the properties used to make that choice with `#[PropertyForMorph]`: ```php - CardPaymentData::class, + 'bank' => BankPaymentData::class, + default => null, + }; + } +} -enum OrderStatus: string +class CardPaymentData extends PaymentData { - case Pending = 'pending'; - case Paid = 'paid'; + public function __construct(string $type, public string $token) + { + parent::__construct($type); + } } + +class BankPaymentData extends PaymentData +{ + public function __construct(string $type, public string $account) + { + parent::__construct($type); + } +} + +$payment = PaymentData::from(['type' => 'card', 'token' => 'tok_123']); + +// CardPaymentData ``` -```php - +### Backed Enums -namespace App\Data; +Backed enums are resolved from their backing values: -use App\Enums\OrderStatus; -use Hypervel\Data\Data; +```php +enum OrderStatus: string +{ + case Pending = 'pending'; + case Paid = 'paid'; +} +``` +```php class OrderData extends Data { public function __construct( @@ -556,28 +874,157 @@ Integer-backed enums accept integral numeric values, including decimal and expon ## Casts and Transformers -Casts convert input into PHP values. Transformers convert PHP values into output. Attach them to a property with `WithCast`, `WithTransformer`, or `WithCastAndTransformer`: +Casts convert input into PHP values. Transformers convert PHP values into output. For example, a money value may be created from an integer amount and transformed back to that amount: ```php -use Hypervel\Data\Attributes\WithCastAndTransformer; +use Hypervel\Data\Attributes\WithCast; +use Hypervel\Data\Attributes\WithTransformer; + +class Money +{ + public function __construct(public int $amount, public string $currency) + { + } +} class InvoiceData extends Data { public function __construct( - #[WithCastAndTransformer(MoneyCast::class)] + public string $currency, + #[WithCast(MoneyCast::class), WithTransformer(MoneyTransformer::class)] public Money $total, ) { } } ``` -A cast implements `Hypervel\Data\Casts\Cast`; a transformer implements `Hypervel\Data\Transformers\Transformer`. Return `Uncastable::create()` from a cast when the next applicable candidate should be tried. Returning `null` means the cast produced a real null value. + +### Custom Casts + +A cast implements `Cast`. Its `cast` method receives the property, its input value, the object's other property values, and the creation options: + +```php +use Hypervel\Data\Casts\Cast; +use Hypervel\Data\Support\Creation\CreationContext; +use Hypervel\Data\Support\DataProperty; + +class MoneyCast implements Cast +{ + public function cast(DataProperty $property, mixed $value, array $properties, CreationContext $context): Money + { + return $value instanceof Money + ? $value + : new Money($value, $properties['currency']); + } +} +``` + +```php +$invoice = InvoiceData::from(['currency' => 'USD', 'total' => 1999]); +$invoice->total->amount; // 1999 +``` + +Return `Hypervel\Data\Casts\Uncastable::create()` when another cast or the built-in conversion should be tried. Returning `null` means the cast produced a real null value. + +Casts and transformers are not called for `null`, which stays `null`. Use a named factory or `prepareForPipeline()` to replace it. + +The `$properties` argument uses PHP property names. Earlier properties have already been cast; later properties contain their input or missing-value defaults. It excludes undeclared input, contextual and computed values, and values the constructor has yet to assign. The `CreationContext` holds options for the whole creation, so its `dataClass` remains the class the creation started with. + +Use `Hypervel\Data\Casts\Castable` when a value class owns its conversion. Its static `dataCastUsing(array $arguments): Cast` method returns the cast. Attach it with `#[WithCastable(EmailAddress::class)]`; attribute arguments are passed to `dataCastUsing` as an array. + +For typed iterable items, implement `IterableItemCast::castIterableItem()` with the same parameters as `cast()`. A cast registered for an item type through configuration or `factory()->withCast()` then applies to every item of that type. -Use `Castable` when a value class owns its input conversion, `IterableItemCast` when a cast also applies to typed iterable items, or `factory()->withCast()` for a single creation. Application-wide replacement casts and transformers belong in `config/data.php`; built-in date, enum, iterable, and `Arrayable` handling does not need to be configured. +#### Casting Nested Data + +A cast on a nested data object or collection receives input before Hypervel constructs its children. With validation enabled, models, objects, and collections are first prepared as arrays and validated against the declared types. The cast receives those validated arrays. Input that only the cast could interpret, such as a string for a nested data object, fails validation. + +Without validation, the cast receives the original value. If every cast declines, ordinary nested creation continues; named factories and constructors run at that point. + + +### Custom Transformers + +A transformer implements `Transformer` and returns a value suitable for output: + +```php +use Hypervel\Data\Support\DataProperty; +use Hypervel\Data\Support\Transformation\TransformationContext; +use Hypervel\Data\Transformers\Transformer; + +class MoneyTransformer implements Transformer +{ + public function transform(DataProperty $property, mixed $value, TransformationContext $context): int + { + return $value->amount; + } +} + +$invoice->toArray(); + +// ['currency' => 'USD', 'total' => 1999] +``` + +You may implement both `Cast` and `Transformer` on one class and attach it with `#[WithCastAndTransformer(MoneyCastAndTransformer::class)]`. + + +### Global Casts and Transformers + +Register application-wide casts and transformers by type in `config/data.php`: + +```php +'casts' => [ + Money::class => MoneyCast::class, +], + +'transformers' => [ + Money::class => MoneyTransformer::class, +], +``` + +These entries are your own extensions; built-in date, enum, iterable, and `Arrayable` handling needs no configuration. Keys may name a concrete class, base class, or interface. Property attributes take precedence. + +During a single creation or transformation, Hypervel may reuse the same extension instance for every matching value. Do not store per-value state on a cast, transformer, or normalizer. + + +## Normalizers Custom normalizers convert a source value into input before Hypervel reads its properties. Declare normalizers for a data class with `normalizers()` or add them to a factory with `withNormalizers()`. Prefer a typed named factory when only one source type needs special handling. -During a single creation or transformation, Hypervel may reuse the same cast, transformer, or normalizer instance for every matching value. Do not store per-value state on the extension object itself. +Implement `Normalizer` and return an array, or `null` when the normalizer does not handle the value: + +```php +use Hypervel\Data\Normalizers\Normalizer; + +class ContactNormalizer implements Normalizer +{ + public function normalize(mixed $value): ?array + { + if (! $value instanceof Contact) { + return null; + } + + return ['name' => $value->name, 'email' => $value->email]; + } +} +``` + +Register it on the data class: + +```php +public static function normalizers(): array +{ + return [ContactNormalizer::class]; +} +``` + +Class normalizers run first, followed by factory normalizers, global normalizers, and built-in conversion. The first matching normalizer wins. Named factories take precedence over normalization. + +By default, a form request is read like any other request, and the data object validates the input with its own rules. To create data objects only from the input a form request has validated, add the optional `FormRequestNormalizer` to the `normalizers` option in `config/data.php`, or to a single class's `normalizers()`: + +```php +'normalizers' => [ + Hypervel\Data\Normalizers\FormRequestNormalizer::class, +], +``` ## Validation @@ -595,42 +1042,109 @@ $validated = UserData::validate($payload); $rules = UserData::getValidationRules($payload); ``` -Hypervel infers presence, nullable, scalar, enum, date, nested data, and typed collection rules from your PHP declarations. These rules cover the entire nested object, including items within typed collections. Uniform collections use wildcard rules, while collections with different item shapes or rules use exact indexed rules. +Validation runs before construction, not when you call `new` or change a public property. For per-operation control, use a [creation factory](#creation-factories). You may also change the default in `config/data.php`: + +```php +use Hypervel\Data\Support\Creation\ValidationStrategy; + +'validation_strategy' => ValidationStrategy::Always->value, +``` + + +### Inferred Rules + +Hypervel infers presence, nullable, scalar, enum, date, nested data, and typed collection rules from your PHP declarations. These rules cover the entire nested object, including items within typed collections. A required collection of data objects must be present but may be empty. Uniform collections use wildcard rules, while collections with different item shapes or rules use exact indexed rules. + +For example, `public int $age` requires an integer, while `public ?string $phone` accepts a string or `null`. Defaults and `Optional` allow input to be omitted. Use `getValidationRules($payload)` to inspect the rules for a particular input; nested rules depend on which values are present. + +The validator receives the complete input, so rules may refer to fields that are not data properties, such as the `password_confirmation` field read by `Confirmed` or a field used by `required_if`. After validation, Hypervel creates the object from the validated values. Properties marked with `#[WithoutValidation]` are preserved, as are existing nested data objects. Other input is discarded. By default, this includes unvalidated keys nested inside an array; calling `Validator::includeUnvalidatedArrayKeys()` during your application's boot retains those nested keys. ### Validation Attributes -Validation attributes mirror Hypervel's validation rules: +Validation attributes mirror Hypervel's validation rules: + +```php +use Hypervel\Data\Attributes\Validation\Email; +use Hypervel\Data\Attributes\Validation\Max; + +class UserData extends Data +{ + public function __construct( + #[Max(100)] + public string $name, + #[Email] + public string $email, + ) { + } +} +``` + +There is no need to add `Required` or `StringType` here; they are inferred from the property declarations. Attributes take precedence over inferred rules of the same type. You may also use `#[Rule('required|string')]` or `#[Rule(['required', 'string'])]` for ordinary rule syntax. See the [validation rule reference](/docs/{{version}}/validation#available-validation-rules) for rule behavior. + +To skip validation for one property, add `#[WithoutValidation]`. Its input is still used during creation, so use this only when that value does not need validation. + +Database-aware `Exists` and `Unique` attributes support the familiar fluent constraints. Within an attribute, use constraint classes such as `WhereConstraint`, `WhereInConstraint`, or `WhereNullConstraint` to add conditions: + +```php +use Hypervel\Data\Attributes\Validation\Exists; +use Hypervel\Data\Support\Validation\Constraints\WhereConstraint; + +#[Exists('users', 'id', where: new WhereConstraint('active', true))] +public int $userId; +``` + +Attribute arguments may also reference values that are only known during validation. `RouteParameterReference` reads a route parameter, `AuthenticatedUserReference` reads the authenticated user, and `ContainerReference` resolves a service from the container. Each reference accepts an optional property to read from the resolved value: + +```php +use Hypervel\Data\Attributes\Validation\Max; +use Hypervel\Data\Attributes\Validation\Unique; +use Hypervel\Data\Support\Validation\References\AuthenticatedUserReference; +use Hypervel\Data\Support\Validation\References\ContainerReference; +use Hypervel\Data\Support\Validation\References\RouteParameterReference; + +class SongData extends Data +{ + public function __construct( + #[Max(new ContainerReference(SongSettings::class, 'maxTitleLength'))] + public string $title, + #[Unique('songs', ignore: new RouteParameterReference('song'))] + public string $slug, + #[Unique('users', 'email', ignore: new AuthenticatedUserReference(guard: 'api'))] + public string $contactEmail, + ) { + } +} +``` + +A missing route parameter throws an exception unless the reference is created with `nullable: true`. A guest resolves to `null`. A container dependency that cannot be resolved throws an exception instead of producing a `null` rule value; pass `parameters` when the dependency needs constructor arguments. + +For a reusable validation attribute, extend `CustomValidationAttribute` and return rules from `getRules()`: ```php -use Hypervel\Data\Attributes\Validation\Email; -use Hypervel\Data\Attributes\Validation\Max; -use Hypervel\Data\Attributes\Validation\Required; +use Attribute; +use Hypervel\Data\Attributes\Validation\CustomValidationAttribute; +use Hypervel\Data\Support\Validation\ValidationPath; -class UserData extends Data +#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_PARAMETER)] +class Slug extends CustomValidationAttribute { - public function __construct( - #[Required, Max(100)] - public string $name, - #[Required, Email] - public string $email, - ) { + public function getRules(ValidationPath $path): array + { + return ['alpha_dash', 'max:100']; } } ``` -Database-aware `Exists` and `Unique` attributes support the familiar fluent constraints. References to another field or an external value use the package's typed validation reference objects rather than interpolated strings. - ### Manual Rules and Hooks -Define `rules`, `messages`, and `attributes` on the data class for rules that cannot be inferred: +Define `rules()` on the data class for rules that cannot be inferred: ```php use Hypervel\Data\Support\Validation\ValidationContext; -use Hypervel\Validation\Validator; public static function rules(ValidationContext $context): array { @@ -640,53 +1154,100 @@ public static function rules(ValidationContext $context): array } ``` -A class rule replaces inferred rules for that property. Add `#[MergeValidationRules]` to merge instead. Property keys use PHP property names; Hypervel translates them to the input paths selected for the current payload. +The `ValidationContext` provides the current object's input as `payload`, the complete input as `fullPayload`, and the object's input `path`. A class rule replaces inferred rules for that property. Add `#[MergeValidationRules]` to the class to merge them instead. Property keys use PHP property names; Hypervel translates them to the mapped input paths. -Use `withValidator(Validator $validator)` and `after(): array` like a FormRequest. Authorization, messages, translated attribute names, error bags, redirects, stop-on-first-failure, Precognition, and `#[FailOnUnknownFields]` use the corresponding Hypervel request-validation behavior. A declared class method overrides the matching Foundation attribute when both are present. +Validation attributes, including `#[Rule]`, may reference another property by its PHP name; Hypervel converts it to the input name. Rule strings returned from `rules()` or rule hooks are passed to the validator as written, so use input names there, or return an attribute such as `new RequiredWith('lastName')`. - -### Creation Factories +For references from a nested object to the root input, use `FieldReference`: + +```php +use Hypervel\Data\Attributes\Validation\RequiredIf; +use Hypervel\Data\Support\Validation\References\FieldReference; -The `factory` method returns a fluent factory for creating data objects: +#[RequiredIf(new FieldReference('requiresAddress', fromRoot: true), true)] +public ?string $street; +``` + + +### Authorizing Requests + +Define `authorize()` to decide whether a request may create the data object. Dependencies are resolved from the container: ```php -$user = UserData::factory() - ->alwaysValidate() - ->prepareData(fn (array $data): array => [ - ...$data, - 'source' => 'import', - ]) - ->withValidator(fn (Validator $validator) => $validator->after($check)) - ->from($payload); +use Hypervel\Contracts\Auth\Access\Gate; + +public static function authorize(Gate $gate): bool +{ + return $gate->allows('create', User::class); +} ``` -Within one operation, you may reuse a factory to avoid repeating creation setup for every payload: +Returning `false` throws an authorization exception. You may instead return a `Hypervel\Auth\Access\Response`. Authorization applies when the source includes a request; creating from an array does not authorize the current request. + + +### Customizing the Validator + +Define `messages()` and `attributes()` to customize errors and field labels: ```php -$factory = UserData::factory(); +public static function messages(): array +{ + return ['email.required' => 'Please provide an email address.']; +} -$users = array_map( - fn (array $payload): UserData => $factory->from($payload), - $payloads, -); +public static function attributes(): array +{ + return ['email' => 'email address']; +} ``` -Use `collect()` instead when the payloads form one collection. In addition to preserving supported collection shapes and keys, `collect()` allows collection validation rules and hooks to inspect the complete payload. +Use `withValidator()` to customize the validator, or return callbacks from `after()` for checks that run after its rules. For example, a class with required integer `start` and `end` properties can compare them after validation: + +```php +use Hypervel\Validation\Validator; -Factories may change the validation strategy, enable or disable name mapping and named factories, ignore selected named methods, and add casts or normalizers. They also provide the following hooks, which run in this order: +public static function after(): array +{ + return [ + function (Validator $validator): void { + if ($validator->errors()->isEmpty() + && $validator->getData()['start'] > $validator->getData()['end'] + ) { + $validator->errors()->add('end', 'The end must follow the start.'); + } + }, + ]; +} +``` -1. `prepareData` -2. `beforeValidation` -3. `beforeRules` -4. `afterRules` -5. `withValidator` -6. `afterValidation` -7. `beforeCreation` -8. `afterCreation` +Use the Foundation HTTP attributes `#[ErrorBag]`, `#[RedirectTo]`, `#[RedirectToRoute]`, `#[StopOnFirstFailure]`, and `#[FailOnUnknownFields]` as with [form requests](/docs/{{version}}/validation#form-request-validation). Static `errorBag()`, `redirect()`, `redirectRoute()`, and `stopOnFirstFailure()` methods override their corresponding attributes. Precognition is also supported. `withValidator` and `after` run on the root data class, not separately on each nested object. -The `prepareData`, `beforeCreation`, and `afterCreation` hooks run even when validation is skipped. The other hooks run while generating rules or validating, as appropriate. Call `alwaysValidate()` when validation hooks should also apply to an array, model, JSON value, or another non-request source. +#### Custom Rule Inferrers -Each call to `factory()` returns a new factory. Keep a reused factory scoped to the current operation instead of storing it across requests. +To adjust every property's inferred rules, add a class to `data.rule_inferrers`. It receives the property, its rules, and a `ValidationContext` after built-in inference: + +```php +use Hypervel\Data\Attributes\Validation\Max; +use Hypervel\Data\Attributes\Validation\StringType; +use Hypervel\Data\RuleInferrers\RuleInferrer; +use Hypervel\Data\Support\DataProperty; +use Hypervel\Data\Support\Validation\PropertyRules; +use Hypervel\Data\Support\Validation\ValidationContext; + +class MaxStringLengthRuleInferrer implements RuleInferrer +{ + public function handle(DataProperty $property, PropertyRules $rules, ValidationContext $context): PropertyRules + { + if ($rules->hasType(StringType::class) && ! $rules->hasType(Max::class)) { + $rules->add(new Max(255)); + } + + return $rules; + } +} +``` + +Rules written in a `Rule` attribute count as their matching attribute types, so inferrers can inspect them by type. Inferrers are resolved from the container for each validation and may depend on scoped services. ## Transformation @@ -703,8 +1264,24 @@ $json = $product->toJson(); `toArray()` recursively transforms nested transformable data, typed iterable items, dates, enums, and `Arrayable` values. The `all()` method returns visible property values without transforming nested values. For more control over a single transformation, pass a `TransformationContext` or `TransformationContextFactory` to `transform()`. +```php +use Hypervel\Data\Support\Transformation\TransformationContextFactory; + +$array = $product->transform( + TransformationContextFactory::create()->withoutPropertyNameMapping(), +); +``` + +The factory also provides `withoutValueTransformation()`, `withWrapping()`, and `withTransformer(Money::class, MoneyTransformer::class)`. + +To guard against infinite recursion, such as when including a recursive relationship, set the `data.max_transformation_depth` configuration option. Reaching that depth throws an exception. Set `data.throw_when_max_transformation_depth_reached` to `false` to transform deeper values into an empty array instead, or pass `throw: false` to a factory's `maxDepth()` method for a single transformation. Values stored by Eloquent casts always throw instead of being truncated. + `Dto` has no transformation API. Use public properties directly, or choose `Data` or `Resource` when output mapping, `Optional` omission, lazy values, or built-in transformation is required. +Data objects do not implement `ArrayAccess`. Read public properties or call `toArray()`. + +When dumped, a transformable data object displays the same values as `all()`. Data collections display their values under an `items` key. Internal package state is not included. By default, this dump format is only used in the `local` and `testing` environments. Set `data.var_dumper_caster_mode` to `enabled` to use it everywhere, or `disabled` to dump the complete object. + ### Lazy Properties @@ -730,16 +1307,60 @@ $user = new UserData( return $user->include('profile')->toArray(); ``` -`Lazy::when` and `Lazy::whenLoaded` add conditional and relation-aware values. `Lazy::closure` returns the closure itself for consumers that understand callback values. Add `#[AutoLazy]`, `#[AutoClosureLazy]`, or `#[AutoWhenLoadedLazy]` to let `from()` wrap supplied values automatically. +#### Conditional and Loaded Relationships + +Use `Lazy::when` to include a value only when a condition passes, or `Lazy::whenLoaded` to include a relationship only when it is already loaded: + +```php +$profile = Lazy::when( + fn (): bool => $user->is_public, + fn (): ProfileData => ProfileData::from($user->profile), +); + +$songs = Lazy::whenLoaded( + 'songs', + $album, + fn () => SongData::collect($album->songs), +); +``` + +The condition decides whether these values appear; `include()` and `exclude()` do not change it. Use `except()` to hide one. To include an ordinary lazy value by default, call `defaultIncluded()`: + +```php +$profile = Lazy::create(fn (): ProfileData => ProfileData::from($user->profile)) + ->defaultIncluded(); +``` + +#### Automatic Lazy Properties + +Add `#[AutoLazy]` to a property, or to the class for every `Lazy` property, to let `from()` wrap supplied values automatically. Use `#[AutoWhenLoadedLazy]` for model relationships: + +```php +use Hypervel\Data\Attributes\AutoWhenLoadedLazy; + +class UserData extends Data +{ + public function __construct( + public string $name, + #[AutoWhenLoadedLazy] + public Lazy|ProfileData $profile, + ) { + } +} +``` + +The relation defaults to the property name. Pass a name such as `#[AutoWhenLoadedLazy('userProfile')]` when it differs. + +`Lazy::closure` and `#[AutoClosureLazy]` return a closure for consumers that understand callback values, such as [Inertia](#inertia). Automatic lazy values postpone creating their nested values until they are included, unless validation needs them first. -When you create a custom `AutoLazy` attribute, its `build()` method receives the original source that supplied the property. If a validation hook changes the property, the method receives the payload returned by that hook instead. When a named factory returns another value for Hypervel to process, that value becomes the source. `AutoWhenLoadedLazy` requires an Eloquent model and throws an exception when no model source is available. +When you create a custom `AutoLazy` attribute, its `build()` method receives the original source that supplied the property. If a validation hook changes the property, the method receives the payload returned by that hook instead. `AutoWhenLoadedLazy` requires an Eloquent model and throws an exception when no model source is available. - -### Partial Trees + +### Including and Excluding Properties -Use `include`, `exclude`, `only`, and `except` to select nested output paths: +Use `include` and `exclude` to control lazy properties, and `only` and `except` to select which properties appear in output: ```php return $user @@ -749,16 +1370,34 @@ return $user ->toArray(); ``` -The ordinary methods apply to the next transformation. Their `Permanently` variants apply to every transformation of that object, and the `When` variants accept a boolean or closure condition. A terminal `*` selects the complete subtree. Invalid partial paths fail instead of being silently ignored. +Paths use PHP property names, even when output names are mapped. Dot notation selects nested properties; `profile.{name,avatar}` selects a group, and a terminal `*` selects the complete subtree. `only` and `except` take precedence over lazy inclusion. -Selections owned by nested objects and collection items are composed with selections from their parent. Temporary selections are consumed only when that object is actually reached; collection reads and iteration do not consume them. +The ordinary methods apply to the next transformation. Their `Permanently` variants apply to every transformation of that object, and the `When` variants accept a boolean or closure condition: + +```php +$user->includePermanently('profile'); +$user->exceptWhen('email', ! $canViewEmail); +``` + +For class-wide defaults, return paths or a map of paths to conditions from `includeProperties()`, `excludeProperties()`, `onlyProperties()`, or `exceptProperties()`: + +```php +protected function includeProperties(): array +{ + return ['profile']; +} +``` + +A malformed path, such as one with an empty segment, always throws an exception. A name that matches no property selects nothing, while a nested path through a property the object does not have throws an exception. Set the `data.ignore_invalid_partials` configuration option to `true` to skip those nested paths instead. + +Selections owned by nested objects and collection items are composed with selections from their parent. An item read from a collection by key or in a loop also carries the collection's selections, so `$songs->include('artist')` applies to `$songs[0]->toArray()`. Temporary selections are consumed only when that object is actually reached; collection reads and iteration do not consume them. ### Hidden, Computed, and Appended Values -`#[Hidden]` omits a declared property from ordinary output. `#[Computed]` marks an output-only property whose value is set by the class; caller input for it is rejected. PHP 8.4 virtual properties are treated as output-only in the same way. +`#[Hidden]` omits a declared property from ordinary output. `#[Computed]` marks an output-only property whose value is set by the class; caller input for it is rejected. To ignore that input instead, set the `data.features.ignore_exception_when_trying_to_set_computed_property_value` configuration option to `true`. PHP 8.4 virtual properties are treated as output-only in the same way. -Return response-only values from `with()` or add them to one object with `additional()`: +Return additional output values from `with()` or add them to one object with `additional()`: ```php public function with(): array @@ -769,7 +1408,7 @@ public function with(): array return $user->additional(['meta' => ['version' => 1]]); ``` -These values are included in HTTP resource responses but are not stored by Eloquent. When a data object is dumped, it displays the same values as `all()`, so hidden, excluded lazy, and `Optional` values are omitted. +These values appear in transformed output and HTTP responses but are not stored by Eloquent. Values may also be closures receiving the data object, such as `fn (UserData $user): string => $user->name`. ## Collections @@ -777,23 +1416,38 @@ These values are included in HTTP resource responses but are not stored by Eloqu Use `collect()` to create several objects while preserving supported source shapes and keys: ```php +use Hypervel\Data\DataCollection; +use Hypervel\Support\Collection; + $users = UserData::collect($rows); $dataCollection = UserData::collect($rows, DataCollection::class); $collection = UserData::collect($rows, Collection::class); $array = UserData::collect($rows, 'array'); ``` -The `$into` argument accepts `null`, `'array'`, or a class name. When the target comes from configuration, narrow it to a `class-string` before passing it so static analysis can infer the return type. When `$into` is `null`, arrays remain arrays, ordinary collections remain collections, and lazy collections remain lazy unless validation needs to read their values. Hypervel paginators are cloned with their pagination details intact. Eloquent collections become base support collections because data objects are not Eloquent models. +The `$into` argument accepts `null`, `'array'`, or a class name. With no target, arrays remain arrays and ordinary collections remain collections. Eloquent collections become base support collections because data objects are not Eloquent models. Paginators retain their pagination details; an array or collection cannot become a paginator because it has no such details. + +Lazy collections remain lazy unless validation needs to read their values. When a target comes from configuration, give it a `class-string` type so static analysis can infer the return type. + +Collecting `null` items into an `'array'` or collection target returns that target empty, which suits optional relations and missing input. Without a target, or into a paginator target, `null` is rejected. `DataCollection`, `PaginatedDataCollection`, and `CursorPaginatedDataCollection` provide typed items, transformation, and response behavior. `DataCollection` also provides keyed access when its underlying collection supports it. Use `toCollection()` for map, filter, reduce, and other collection operations. Paginated data collections cannot be stored directly by Eloquent because their pagination details cannot be recreated from a JSON array. Store their items through a `DataCollection` instead. +```php +$names = $dataCollection->toCollection()->map(fn (UserData $user): string => $user->name); + +foreach ($dataCollection as $user) { + // Work with each UserData object... +} +``` + When a source remains lazy, every traversal creates its items again, and calling `count()` also traverses the source. If you need the items more than once, collect them into an eager collection and reuse it: ```php $eagerUsers = $dataCollection->toCollection()->collect(); ``` -When validation is enabled, Hypervel validates the complete collection at once so collection rules and hooks can work with the entire payload. When collecting Eloquent models, Hypervel loads any relations requested with `#[LoadRelation]` before creating the data objects. +When validation is enabled, Hypervel validates the complete collection at once so collection rules and hooks can work with the entire payload. ## HTTP Resources @@ -806,22 +1460,106 @@ return UserData::from($user); return UserData::collect($users, DataCollection::class); ``` -Responses use Hypervel's JSON resources and paginator support, including their links and pagination details. Use `wrap()` or `withoutWrapping()` on an object or collection. You may also define the default wrapper using the `data.wrap` configuration option. + +### Wrapping -Override static `jsonOptions()` or `withResponse(Request $request, JsonResponse $response)` for Laravel-style response customization. Query-string `include`, `exclude`, `only`, and `except` selections are disabled unless the data class allows them through `allowedRequestIncludes()`, `allowedRequestExcludes()`, `allowedRequestOnly()`, or `allowedRequestExcept()`. +Responses use Hypervel's JSON resources. To wrap the output in a `data` key, call `wrap`: - -## Form Request Casting +```php +return UserData::from($user)->wrap('data'); -Declare a `Data`, `Dto`, or `Resource` class directly in a form request's protected `casts` method. The request creates the object after validation and returns it from `validated()` and `safe()`: +// {"data": {"name": "Taylor", ...}} +``` + +Call `withoutWrapping()` to disable wrapping for an object or collection. Define `defaultWrap(): string` on a class, or set `data.wrap` in configuration, to choose a default. + +Wrapping applies to responses; `toArray()` and `toJson()` don't wrap, apart from paginated output, described below. Within a wrapped response, a nested data object is never wrapped. A data collection keeps its own wrapper when it is a property of the returned data object, but not when it sits inside a nested data object or a collection item. A nested array or collection of data objects uses the `data.wrap` key. + + +### Paginated Responses + +Pass a paginator to `collect` and choose the corresponding data collection: + +```php +use Hypervel\Data\CursorPaginatedDataCollection; +use Hypervel\Data\PaginatedDataCollection; + +return UserData::collect(User::paginate(), PaginatedDataCollection::class); + +return UserData::collect(User::cursorPaginate(), CursorPaginatedDataCollection::class); +``` + +A paginated data collection, or a paginator property on a data object, is always wrapped, using `data` when no wrapper is set. Its `links` and `meta` describe the current page. Responses, `toArray()`, and `toJson()` produce the same shape. For example, a single-page result with one user contains: + +```json +{ + "data": [{"name": "Taylor"}], + "links": [ + {"url": null, "label": "« Previous", "page": null, "active": false}, + {"url": "https://example.com/users?page=1", "label": "1", "page": 1, "active": true}, + {"url": null, "label": "Next »", "page": null, "active": false} + ], + "meta": { + "current_page": 1, + "first_page_url": "https://example.com/users?page=1", + "from": 1, + "last_page": 1, + "last_page_url": "https://example.com/users?page=1", + "next_page_url": null, + "path": "https://example.com/users", + "per_page": 15, + "prev_page_url": null, + "to": 1, + "total": 1 + } +} +``` + +A length-aware paginator's `links` holds its page links. A cursor paginator has no page links, and its `meta` holds `path`, `per_page`, `next_cursor`, `next_page_url`, `prev_cursor`, and `prev_page_url`. A simple paginator's `meta` holds the values from its `toArray()` other than its items. + + +### Customizing Responses + +Override static `jsonOptions()` or `withResponse(Request $request, JsonResponse $response)` to customize the response. A custom data collection class may also override `withResponse()`, while its JSON options come from its data class: + +```php +use Hypervel\Http\JsonResponse; +use Hypervel\Http\Request; + +public function withResponse(Request $request, JsonResponse $response): void +{ + $response->headers->set('X-API-Version', '1'); +} +``` + +Responses default to `200` for every request method. Set `201` when an action actually creates something: + +```php +return UserData::from($user)->toResponse($request)->setStatusCode(201); +``` + + +### Selecting Properties From Requests + +You may let clients choose lazy properties through the query string. On the data class, declare which properties may be included: ```php - +## Form Request Casting + +Declare a `Data`, `Dto`, or `Resource` class directly in a form request's protected `casts` method. The request creates the object after validation and returns it from `validated()` and `safe()`: +```php use App\Data\AddressData; use App\Data\ContactData; use App\Data\ProfileDto; @@ -843,7 +1581,7 @@ 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. +The object is built through its normal `from()` method. 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. @@ -866,12 +1604,6 @@ For more information on request input casting, see the [validation documentation `Data`, `Resource`, and `DataCollection` implement Eloquent's `Castable` contract. Use the data class directly in a model's cast declaration: ```php -profile = UserProfileData::from($profile); +$user->save(); + +$user->refresh()->profile; // UserProfileData +``` + +A data collection attribute may be assigned a data collection, or an array, collection, or other `Arrayable` value containing data objects or input arrays. + Eloquent stores all values needed to recreate the data object using its PHP property names. Hidden properties are stored, while computed, virtual, appended, and response-only values are omitted. Partial selections do not change the stored value and are not consumed. Output transformers still run, so a one-way transformer needs a matching input cast or `WithCastAndTransformer` to recreate the original value. Conditional and relation lazy values must already be included when the model is saved, and saving never loads a relation. Closure and Inertia lazy values cannot be stored because they do not resolve to ordinary data values. -Both casts support `encrypted` and `default` arguments. Abstract data classes use an enforced alias map unless they select a concrete subtype through `PropertyMorphableData::morph()`: + +### Defaults and Encryption + +A database `null` normally remains `null`. Add `default` to create an object with its declared defaults, or an empty collection. Use `encrypted` to encrypt the stored value: + +```php +protected function casts(): array +{ + return [ + 'profile' => UserProfileData::class . ':default', + 'members' => DataCollection::class . ':' . MemberData::class . ',default,encrypted', + ]; +} +``` + +The data class must be constructable without input for `default` to succeed. Encrypted values need a text column large enough for the ciphertext, as described in [encrypted casting](/docs/{{version}}/eloquent-mutators#encrypted-casting). + + +### Abstract Classes + +A value cast to an abstract data class is stored with the class name of its concrete subtype. Registering aliases stores short names instead, which stay valid when classes are renamed: ```php use Hypervel\Data\Support\DataConfig; @@ -901,13 +1664,15 @@ use Hypervel\Data\Support\DataConfig; public function boot(DataConfig $data): void { $data->enforceMorphMap([ - 'card' => CardPaymentData::class, - 'bank' => BankPaymentData::class, + 'card' => CardSettingsData::class, + 'bank' => BankSettingsData::class, ]); } ``` -Morph maps are boot-time configuration. Unknown aliases and payload-provided class names are rejected. +Morph maps are boot-time configuration. A stored alias or class name must resolve to a concrete subtype of the cast's abstract class; anything else is rejected when the value is read. + +An abstract class implementing [`PropertyMorphableData`](#abstract-data-objects) instead uses its own stored properties to select the subtype. It does not store a separate class name or alias. For more information on Eloquent casts, see the [Eloquent mutators and casts documentation](/docs/{{version}}/eloquent-mutators#data-object-casting). @@ -933,9 +1698,13 @@ class UpdatePostData extends Data } ``` -Contextual values are resolved only after validation succeeds. They always take precedence over input and creation hooks, including when the resolved value is `null`. Input supplied for a promoted contextual property is allowed by strict unknown-field validation but is ignored. A non-promoted contextual parameter with its own name is passed only to the constructor. Use a named factory or creation hook without the contextual attribute when input should take precedence. +The contextual value always takes precedence over input and creation hooks, even when it is `null`. For example, client input cannot replace the current user's ID. Values are converted to the declared type, so a `'123'` route parameter becomes an `int`. + +When validating a promoted property, Hypervel resolves its contextual value before validation. Its own rules and other rules referencing it see that value. Without validation, resolution happens during construction. Input supplied for a promoted contextual property is ignored, including under strict unknown-field validation. If a rule such as `exclude_if` excludes the contextual value, the property is treated as missing. -`CurrentUser` and `RouteParameter` accept an optional `property` path and use `data_get()` semantics. Accessors and Eloquent relations may run while traversing that path. `RequestAttribute` selects an exact request attribute key. The `Config`, `Context`, and `Give` attributes are also supported, as are custom contextual attributes. +A non-promoted contextual parameter is passed only to the constructor. When input should take precedence, omit the contextual attribute and use a named factory or creation hook. + +`CurrentUser`, `RouteParameter`, and `Give` accept an optional `property` path and use `data_get()` semantics. Accessors and Eloquent relations may run while traversing that path. `RequestAttribute` selects an exact request attribute key. The `Config`, `Context`, and `Give` attributes are also supported, as are custom contextual attributes. ## Inertia @@ -943,19 +1712,29 @@ Contextual values are resolved only after validation succeeds. They always take When `hypervel/inertia` is installed, Data lazy values can produce Inertia props: ```php -public function __construct( - public Lazy|ProfileData $profile, - public Lazy|ActivityData $activity, -) { +use Hypervel\Data\Lazy; +use Hypervel\Inertia\Inertia; + +class DashboardData extends Data +{ + public function __construct( + public Lazy|ProfileData $profile, + public Lazy|ActivityData $activity, + ) { + } } $data = new DashboardData( Lazy::inertia(fn () => ProfileData::from($profile)), Lazy::inertiaDeferred(fn () => ActivityData::from($activity), group: 'activity'), ); + +return Inertia::render('Dashboard', $data); ``` -`#[AutoInertiaLazy]` and `#[AutoInertiaDeferred]` provide automatic variants. Existing `DeferProp` instances retain their merge, caching, grouping, and rescue settings. +`Lazy::inertia()` omits the property on the initial visit and evaluates it when requested in a partial reload. `Lazy::closure()` is included on the initial visit and evaluated only when needed on partial reloads. `Lazy::inertiaDeferred()` loads the property after the initial render; properties with the same group load together. + +`#[AutoInertiaLazy]`, `#[AutoClosureLazy]`, and `#[AutoInertiaDeferred('activity')]` provide automatic variants. Existing `DeferProp` instances retain their merge, caching, grouping, and rescue settings. See the [frontend documentation](/docs/{{version}}/frontend) for more on Inertia. ## Saloon @@ -965,9 +1744,10 @@ Hypervel Saloon may return a Data object directly from `createDtoFromResponse`: ```php use Hypervel\Data\Data; use Hypervel\Saloon\Contracts\DataObjects\WithResponse; +use Hypervel\Saloon\Http\Response; use Hypervel\Saloon\Traits\Responses\HasResponse; -final class GitHubUserData extends Data implements WithResponse +class GitHubUserData extends Data implements WithResponse { use HasResponse; @@ -986,16 +1766,68 @@ public function createDtoFromResponse(Response $response): GitHubUserData Saloon attaches the response to the data object through its existing `WithResponse` contract. - -## Generating Data Classes + +## Lightweight Data Objects -Generate a class with the `make:data` command: +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. -```shell -php bin/hypervel.php make:data UserData +To define a lightweight data object, extend `DataObject` and promote every constructor parameter as a public property: + +```php +use Hypervel\Support\CarbonImmutable; +use Hypervel\Support\DataObject; + +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, + ) { + } +} + +$message = MessageEnvelope::from([ + 'id' => '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']); ``` -The class is placed under your application's `Data` namespace, normally `App\Data`. The command does not append a suffix, so supply the complete class name you want. Like Hypervel's other generators, it honors an application stub override and supports `--force`. +Use Hypervel Data and `DataCollection` when a reusable typed collection needs validation, mapping, transformation controls, or response behavior. ## Worker Lifetime @@ -1004,10 +1836,6 @@ Hypervel analyzes each data class when it is first used and keeps that descripti Register `Lazy`, `DataCollection`, `PaginatedDataCollection`, and `CursorPaginatedDataCollection` macros during provider boot. These macros remain registered for the worker lifetime, so they must not contain request-specific callbacks or values. Configure morph aliases during boot for the same reason. -When dumped, a transformable data object displays the same values as `all()`. Data collections display their values under an `items` key. Internal package state is not included. - -Data objects do not implement `ArrayAccess`. Read public properties or call `toArray()`. Data collections support enumeration and provide keyed access when their underlying collection supports it. - ## Credits diff --git a/src/docs/porting-from-laravel.md b/src/docs/porting-from-laravel.md index 9d17fe5edd..c59ca2d954 100644 --- a/src/docs/porting-from-laravel.md +++ b/src/docs/porting-from-laravel.md @@ -574,9 +574,26 @@ Laravel's deprecated `InvokableRule` contract is not available. Change rules tha When porting `spatie/laravel-data`, replace its namespace with `Hypervel\Data` and review the [Data Objects documentation](/docs/{{version}}/data-objects). The familiar `Data`, `Dto`, `Resource`, `Optional`, mapping, casting, validation, lazy-value, collection, resource, and Eloquent APIs are all available. -Replace Spatie's `From*` attributes with Hypervel contextual constructor attributes and its `withOptionalValues()` and `withoutOptionalValues()` factory switches with declared `Optional` unions. `SerializeTransformer` and `UnserializeCast` are not included; use native PHP serialization or explicit custom casts and transformers. Livewire and TypeScript integrations are also not included. - -Model attributes containing `null` remain explicit values, including for non-nullable properties with defaults. When several payloads are supplied to `from()`, the first payload containing a property's input key wins, including when its value is `null`. +Replace Spatie's `From*` attributes with Hypervel contextual constructor attributes. `UnserializeCast` is not included; write a custom cast that passes `allowed_classes` to `unserialize()` for trusted values. Livewire and TypeScript integrations are also not included. + +In `config/data.php`, the `casts`, `transformers`, `normalizers`, and `rule_inferrers` options only hold your own extensions; remove Spatie's built-in entries, since Hypervel's built-in handling is fixed. If you enabled Spatie's optional `FormRequestNormalizer`, keep it as `Hypervel\Data\Normalizers\FormRequestNormalizer`. Typed iterable items are always cast and transformed, and an array given to a collection property becomes that collection. + +Review these behavior differences in ported code: + +- `Resource` authorizes and validates request input, like `Data` and `Dto`. +- A named factory that receives a request and returns the finished object must validate the request itself. +- When `from()` receives several payloads, the combined input is validated once, and a later explicit `null` replaces an earlier value. +- Responses use the `200` status code for `POST` requests. Set `201` in `withResponse()` instead of overriding `calculateResponseStatus()`. +- Data classes whose properties share an input path or output key are rejected when first used. +- Custom `pipeline()` overrides and `DataPipe` classes are not supported. Rebuild them with named factories, `prepareForPipeline()`, or factory hooks. +- A value that a union property already accepts is kept, such as a string for `string|SongData` or an array for `array|Collection`, and validation applies the rules of the declared type that holds it. Declare `Collection` alone when the property should always hold a collection. See [type conversion](/docs/{{version}}/data-objects#type-conversion). +- A model attribute holding `null` is passed as `null` instead of falling back to the property's default or `Optional`. Columns that were not selected still count as missing. +- A custom cast's `$properties` contains only declared property values keyed by PHP property name, without undeclared input or raw input names. +- `CreationContext::$dataClass` is the class the creation started with, even while nested objects are created, and the context has no `from()`, `collect()`, or `currentPath`. Replace `$context->from()` with `TargetData::factory($context)->from()`, which copies the context's options but not its hooks. A cast can read `$property->className` for the class that declares the property. +- An array or collection cannot be collected into a paginator target or given to a paginator property. Pass a Hypervel paginator so its pagination details are kept. +- A required property declared outside the constructor that receives no input, and no value from its default or the constructor, fails with `CannotCreateData` instead of staying uninitialized. +- Casts and transformers that read Spatie's metadata need small changes. `DataProperty` has `hasDefaultValue` but no `defaultValue`. Hypervel reads defaults from reflection when it needs them, so a default such as `new Money(0)` is never one object shared by every request in the worker; read it from the constructor parameter's or property's reflection. `DataClass` exposes `constructor` and `constructorParameters` instead of `constructorMethod`, and `TransformationContext::$transformers` is an array of factory transformers keyed by type instead of a `GlobalTransformersCollection`. Likewise, `withCastCollection()` and `CreationContext::$casts` use an array of casts keyed by type instead of a `GlobalCastsCollection`. Create factories with `Data::factory()` instead of `CreationContextFactory::createFromConfig()`, and read their options through `get()`. +- Replace `getDataContext()` with `getPartialsDefinition()` and `getWrap()`, and `make:data --namespace` with `--target-namespace` and a complete namespace. ### Rate Limiting diff --git a/src/inertia/src/ScrollMetadata.php b/src/inertia/src/ScrollMetadata.php index f154fd1bcf..658a6d3b53 100644 --- a/src/inertia/src/ScrollMetadata.php +++ b/src/inertia/src/ScrollMetadata.php @@ -5,6 +5,8 @@ namespace Hypervel\Inertia; use Hypervel\Contracts\Support\Arrayable; +use Hypervel\Data\CursorPaginatedDataCollection; +use Hypervel\Data\PaginatedDataCollection; use Hypervel\Http\Resources\Json\JsonResource; use Hypervel\Pagination\CursorPaginator; use Hypervel\Pagination\LengthAwarePaginator; @@ -32,7 +34,12 @@ public function __construct( */ public static function fromPaginator(mixed $value): self { - $paginator = $value instanceof JsonResource ? $value->resource : $value; + $paginator = match (true) { + $value instanceof JsonResource => $value->resource, + // The Data package is optional; its paginated collections own their paginator. + $value instanceof PaginatedDataCollection, $value instanceof CursorPaginatedDataCollection => $value->items(), + default => $value, + }; if ($paginator instanceof CursorPaginator) { return new self( diff --git a/src/routing/src/CallableDispatcher.php b/src/routing/src/CallableDispatcher.php index 826b13f073..d97f624e88 100644 --- a/src/routing/src/CallableDispatcher.php +++ b/src/routing/src/CallableDispatcher.php @@ -6,6 +6,7 @@ use Closure; use Hypervel\Container\Container; +use Hypervel\Container\NativeInvoker; use Hypervel\Context\RequestContext; use Hypervel\Foundation\Routing\PrecognitionCallableDispatcher; use Hypervel\Routing\Contracts\CallableDispatcher as CallableDispatcherContract; @@ -63,7 +64,7 @@ public function dispatch(Route $route, callable $callable): mixed return $parameters === [] ? $callable() - : $callable(...array_values($parameters)); + : NativeInvoker::call($callable, array_values($parameters)); } /** diff --git a/src/routing/src/Controller.php b/src/routing/src/Controller.php index dfeaada861..a6afa286be 100644 --- a/src/routing/src/Controller.php +++ b/src/routing/src/Controller.php @@ -6,6 +6,7 @@ use BadMethodCallException; use Closure; +use Hypervel\Container\NativeInvoker; abstract class Controller { @@ -42,7 +43,9 @@ public function getMiddleware(): array */ public function callAction(string $method, array $parameters): mixed { - return $this->{$method}(...array_values($parameters)); + return $parameters === [] + ? $this->{$method}() + : NativeInvoker::call($this->{$method}(...), array_values($parameters)); } /** diff --git a/src/routing/src/ControllerDispatcher.php b/src/routing/src/ControllerDispatcher.php index 05ffdab722..41881aa527 100644 --- a/src/routing/src/ControllerDispatcher.php +++ b/src/routing/src/ControllerDispatcher.php @@ -5,6 +5,7 @@ namespace Hypervel\Routing; use Hypervel\Container\Container; +use Hypervel\Container\NativeInvoker; use Hypervel\Context\RequestContext; use Hypervel\Foundation\Routing\PrecognitionControllerDispatcher; use Hypervel\Routing\Contracts\ControllerDispatcher as ControllerDispatcherContract; @@ -63,7 +64,9 @@ public function dispatch(Route $route, mixed $controller, string $method): mixed return $controller->callAction($method, $parameters); } - return $controller->{$method}(...array_values($parameters)); + return $parameters === [] + ? $controller->{$method}() + : NativeInvoker::call($controller->{$method}(...), array_values($parameters)); } /** diff --git a/src/support/src/Facades/App.php b/src/support/src/Facades/App.php index e74e0a3def..3c45c14d58 100644 --- a/src/support/src/Facades/App.php +++ b/src/support/src/Facades/App.php @@ -101,6 +101,7 @@ * @method static \Hypervel\Support\ServiceProvider register(\Hypervel\Support\ServiceProvider|string $provider, bool $force = false) * @method static void registerConfiguredProviders() * @method static void registered(callable $callback) + * @method static array resolveContextualParameters(string $concrete, null|array $names = null) * @method static bool resolvedScoped(string $abstract) * @method static void resolveEnvironmentUsing(null|callable $callback) * @method static mixed resolveFromAttribute(\ReflectionAttribute $attribute, \ReflectionParameter $parameter) diff --git a/tests/Benchmarks/Data/README.md b/tests/Benchmarks/Data/README.md index a1f92d5c06..5d175f608c 100644 --- a/tests/Benchmarks/Data/README.md +++ b/tests/Benchmarks/Data/README.md @@ -2,6 +2,8 @@ This developer-only harness measures Data construction against native constructors and explicit array mapping, plus collection, validation, named-factory, transforming and non-transforming output, resource-response, metadata, Eloquent persistence, and relation-loading paths. It is not registered as an Artisan command and is not part of the PHPUnit suite. +Other load on the machine skews the results, so get confirmation from the owner that the machine is idle before running a benchmark. + Run it from the components repository root: ```shell diff --git a/tests/Benchmarks/Data/benchmark.php b/tests/Benchmarks/Data/benchmark.php index 5c09fe7466..5806a0c612 100644 --- a/tests/Benchmarks/Data/benchmark.php +++ b/tests/Benchmarks/Data/benchmark.php @@ -18,7 +18,6 @@ use Hypervel\Data\DataServiceProvider; use Hypervel\Data\Eloquent\DataEloquentCast; use Hypervel\Data\Lazy; -use Hypervel\Data\Support\Creation\ConstructionState; use Hypervel\Data\Support\Creation\CreationContext; use Hypervel\Data\Support\DataClassRepository; use Hypervel\Data\Support\DataProperty; @@ -31,6 +30,7 @@ use Hypervel\Database\Events\QueryExecuted; use Hypervel\Database\Schema\Blueprint; use Hypervel\Http\Request; +use Hypervel\Support\Arr; use Hypervel\Support\ClassMetadataCache; use Hypervel\Support\LazyCollection; use Hypervel\Testbench\Bootstrapper; @@ -66,6 +66,29 @@ public function __construct( } } +class DataBenchmarkPreparedUser extends Data +{ + /** + * Create a benchmark user prepared from flat address input. + */ + public function __construct( + public int $id, + public string $name, + public DataBenchmarkAddress $address, + ) { + } + + /** + * Group the flat address fields into the nested address payload. + */ + public static function prepareForPipeline(array $properties): array + { + $properties['address'] = Arr::only($properties, ['line_one', 'city', 'country_code']); + + return $properties; + } +} + class DataBenchmarkColdData extends Data { public function __construct( @@ -209,7 +232,7 @@ class DataBenchmarkPrefixCast implements Cast public function cast( DataProperty $property, mixed $value, - ConstructionState $state, + array $properties, CreationContext $context, ): string { return 'cast:' . $value; @@ -430,11 +453,23 @@ public function execute(): array $plainFiveData = new DataBenchmarkPlainFive; $plainTwentyData = new DataBenchmarkPlainTwenty; $request = Request::create('/'); + $validationRequest = Request::create('/', 'POST', [...$nestedPayload, 'source' => 'web', 'locale' => 'en']); + $preparedPayload = [ + 'id' => 1001, + 'name' => 'Taylor Otwell', + 'line_one' => '1 Framework Way', + 'city' => 'Little Rock', + 'country_code' => 'US', + ]; $model = new DataBenchmarkUserModel; /** @var DataEloquentCast $eloquentCast */ $eloquentCast = new DataEloquentCast(DataBenchmarkUser::class); $encodedNestedData = $eloquentCast->set($model, 'payload', $nestedData, []) ?? throw new LogicException('The benchmark Data cast returned null for a Data value.'); + $partialCollection = DataBenchmarkUser::collect( + array_fill(0, 1_000, $nestedPayload), + DataCollection::class, + ); $lazyTransformData = DataBenchmarkLazyItem::from($lazyRows[0]) ->includePermanently('address') ->onlyPermanently('id', 'address.lineOne'); @@ -458,8 +493,8 @@ public function execute(): array $standardOperations = $this->operations; $nestedOperations = $this->scaledOperations(10); - $collectionOperations = $this->scaledOperations(100); - $validationOperations = $this->scaledOperations(1_000); + $collectionOperations = $this->scaledOperations(1_000); + $validationOperations = $this->scaledOperations(10_000); $standardWarmup = $this->warmup; $nestedWarmup = $this->scaledWarmup(10); $collectionWarmup = $this->scaledWarmup(100); @@ -502,6 +537,16 @@ public function execute(): array $nestedWarmup, fn (): int => DataBenchmarkRoot::from($deepPayload)->child->child->child->id, ], + 'data-from-multiple-payloads' => [ + $nestedOperations, + $nestedWarmup, + fn (): int => DataBenchmarkUser::from(['active' => false], $flatPayload)->id, + ], + 'data-from-prepared-input' => [ + $nestedOperations, + $nestedWarmup, + fn (): int => DataBenchmarkPreparedUser::from($preparedPayload)->address->countryCode === 'US' ? 1 : 0, + ], 'collect-1000-eager' => [ $collectionOperations, $collectionWarmup, @@ -536,6 +581,16 @@ function () use ($collectionRows): int { ->collect($validationRows, DataCollection::class) ->count(), ], + 'validate-flat' => [ + $nestedOperations, + $nestedWarmup, + fn (): int => DataBenchmarkValidatedItem::validateAndCreate($validationRows[0])->id, + ], + 'validate-request-nested' => [ + $nestedOperations, + $nestedWarmup, + fn (): int => DataBenchmarkUser::from($validationRequest)->id, + ], 'factory-direct' => [ $standardOperations, $standardWarmup, @@ -596,6 +651,11 @@ function () use ($collectionRows): int { $standardWarmup, fn (): int => $nestedData->toArray()['address']['countryCode'] === 'US' ? 1 : 0, ], + 'transform-collection-partial' => [ + $collectionOperations, + $collectionWarmup, + fn (): int => count($partialCollection->only('id', 'address.city')->toArray()), + ], 'transform-lazy-partial' => [ $nestedOperations, $nestedWarmup, @@ -876,6 +936,9 @@ private function percentile(array $samples, float $percentile): float private function environment(): array { $commit = trim((string) shell_exec('git rev-parse HEAD 2>/dev/null')); + // The ini settings can differ from what is running, so the status reports OPcache and JIT as they are. + $opcache = function_exists('opcache_get_status') ? opcache_get_status(false) : false; + $jitActive = is_array($opcache) && ($opcache['jit']['on'] ?? false); return [ 'timestamp' => gmdate(DATE_ATOM), @@ -883,8 +946,8 @@ private function environment(): array 'php' => PHP_VERSION, 'os' => php_uname(), 'extensions' => get_loaded_extensions(), - 'opcache_enabled' => ini_get('opcache.enable_cli') ?: '0', - 'jit' => ini_get('opcache.jit') ?: 'disabled', + 'opcache_enabled' => is_array($opcache) && $opcache['opcache_enabled'] ? '1' : '0', + 'jit' => $jitActive ? (string) ini_get('opcache.jit') : 'disabled', 'operations_per_sample' => $this->operations, 'samples' => $this->samples, 'warmup_operations' => $this->warmup, diff --git a/tests/Container/ContainerCallTest.php b/tests/Container/ContainerCallTest.php index 8758282ad0..49184b303a 100644 --- a/tests/Container/ContainerCallTest.php +++ b/tests/Container/ContainerCallTest.php @@ -17,9 +17,22 @@ use ReflectionProperty; use RuntimeException; use stdClass; +use TypeError; class ContainerCallTest extends TestCase { + public function testCallsAndConstructorsConvertScalarsWithPhpWeakTyping(): void + { + $container = new Container; + + $this->assertSame(5, $container->call(static fn (int $id): int => $id, ['id' => '5'])); + $this->assertSame(30, $container->make(ContainerCallTypedScalarStub::class, ['timeout' => '30'])->timeout); + + $this->expectException(TypeError::class); + + $container->call(static fn (int $id): int => $id, ['id' => 'abc']); + } + public function testCallWithAtSignBasedClassReferencesWithoutMethodThrowsException(): void { $this->expectException(Error::class); @@ -609,6 +622,17 @@ public function unresolvable($foo, $bar) } } +class ContainerCallTypedScalarStub +{ + /** + * Create a new stub instance. + */ + public function __construct( + public int $timeout, + ) { + } +} + class ContainerCallConcreteStub { } diff --git a/tests/Container/ContextualAttributeBindingTest.php b/tests/Container/ContextualAttributeBindingTest.php index 15baa5e970..b514f16374 100644 --- a/tests/Container/ContextualAttributeBindingTest.php +++ b/tests/Container/ContextualAttributeBindingTest.php @@ -98,6 +98,28 @@ public function testComplexDependencyCanBeResolvedCorrectlyFromGiveAttributeBind $this->assertTrue($resolution->dependency->param); } + public function testGiveAttributeCanExtractAPropertyPath(): void + { + $container = new Container; + + $resolution = $container->make(GivePropertyTest::class); + + $this->assertTrue($resolution->param); + $this->assertNull($resolution->missing); + $this->assertFalse($container->isScoped(GivePropertyTest::class)); + } + + public function testGivePropertyRejectsScalarValues(): void + { + $container = new Container; + $container->bind(GiveScalarSource::class, fn (): int => 123); + + $this->expectException(BindingResolutionException::class); + $this->expectExceptionMessageIs('Cannot extract property path [id] from scalar [int] resolved by [Hypervel\Container\Attributes\Give].'); + + $container->make(ScalarGivePropertyTest::class); + } + public function testScalarDependencyCanBeResolvedFromAttributeBinding(): void { $container = new Container; @@ -141,6 +163,48 @@ public function testDependencyWithAfterCallbackAttributeCanBeResolved(): void $this->assertSame('Developer', $class->person->role); } + public function testContextualParametersResolveInTheClassBuildContext(): void + { + $container = new Container; + $container->bind(ContainerTestContract::class, ContainerTestImplB::class); + $container->when(ContainerTestHasContextualDependencies::class) + ->needs(ContainerTestContract::class) + ->give(ContainerTestImplA::class); + $callbacks = 0; + $container->afterResolvingAttribute( + ContainerTestResolvesContractThroughContainer::class, + function () use (&$callbacks): void { + ++$callbacks; + }, + ); + + $values = $container->resolveContextualParameters(ContainerTestHasContextualDependencies::class); + + $this->assertSame(['dependency', 'person'], array_keys($values)); + $this->assertInstanceOf(ContainerTestImplA::class, $values['dependency']); + $this->assertSame('Developer', $values['person']->role); + $this->assertSame(1, $callbacks); + + // The class's build context ends with the call. + $this->assertInstanceOf(ContainerTestImplB::class, $container->make(ContainerTestContract::class)); + + $selected = $container->resolveContextualParameters(ContainerTestHasContextualDependencies::class, ['person']); + + $this->assertSame(['person'], array_keys($selected)); + $this->assertSame(1, $callbacks); + } + + public function testContextualParametersLeaveVariadicParametersToTheBuild(): void + { + $container = new Container; + $container->singleton('config', fn (): Repository => new Repository(['names' => ['a', 'b']])); + + $values = $container->resolveContextualParameters(ContainerTestHasVariadicContextualDependency::class); + + $this->assertSame([], $values); + $this->assertSame(['a', 'b'], $container->buildWith(ContainerTestHasVariadicContextualDependency::class, $values)->names); + } + public function testAuthedAttribute(): void { $container = new Container; @@ -790,6 +854,46 @@ public function resolve(self $attribute, Container $container, ReflectionParamet } } +#[Attribute(Attribute::TARGET_PARAMETER)] +final class ContainerTestResolvesContractThroughContainer implements ContextualAttribute +{ + /** + * Resolve the contract through the container. + */ + public static function resolve(self $attribute, Container $container): ContainerTestContract + { + return $container->make(ContainerTestContract::class); + } +} + +final class ContainerTestHasContextualDependencies +{ + /** + * Create a new test fixture. + */ + public function __construct( + #[ContainerTestResolvesContractThroughContainer] + public ContainerTestContract $dependency, + public string $plain, + #[ContainerTestConfigValueWithResolveAndAfter] + public object $person, + ) { + } +} + +class ContainerTestHasVariadicContextualDependency +{ + public array $names; + + /** + * Create a new test fixture. + */ + public function __construct(#[Config('names')] string ...$names) + { + $this->names = $names; + } +} + final class ContainerTestHasConfigValueWithResolvePropertyAndAfterCallback { /** @@ -1160,6 +1264,36 @@ public function __construct( } } +final readonly class GivePropertyTest +{ + /** + * Create a new test fixture. + */ + public function __construct( + #[Give(ComplexDependency::class, ['param' => true], property: 'param')] + public bool $param, + #[Give(ComplexDependency::class, ['param' => true], property: 'missing')] + public ?string $missing, + ) { + } +} + +interface GiveScalarSource +{ +} + +final readonly class ScalarGivePropertyTest +{ + /** + * Create a new test fixture. + */ + public function __construct( + #[Give(GiveScalarSource::class, property: 'id')] + public ?int $id, + ) { + } +} + final readonly class TimezoneObject { /** diff --git a/tests/Data/AppendTest.php b/tests/Data/AppendTest.php new file mode 100644 index 0000000000..c712703891 --- /dev/null +++ b/tests/Data/AppendTest.php @@ -0,0 +1,127 @@ + "{$this->name} from Spatie"]; + } + }; + + $this->assertSame([ + 'name' => 'Freek', + 'alt_name' => 'Freek from Spatie', + ], $data->toArray()); + } + + public function testCanAppendDataViaMethodOverwritingWithClosures(): void + { + $data = new class('Freek') extends Data { + /** + * Create a named data object. + */ + public function __construct(public string $name) + { + } + + /** + * Get the data appended to the output. + */ + public function with(): array + { + return [ + 'alt_name' => static function (self $data): string { + return $data->name . ' from Spatie via closure'; + }, + ]; + } + }; + + $this->assertSame([ + 'name' => 'Freek', + 'alt_name' => 'Freek from Spatie via closure', + ], $data->toArray()); + } + + public function testCanAppendDataViaMethodCall(): void + { + $data = new class('Freek') extends Data { + /** + * Create a named data object. + */ + public function __construct(public string $name) + { + } + }; + + $transformed = $data->additional([ + 'company' => 'Spatie', + 'alt_name' => fn (Data $data): string => "{$data->name} from Spatie", + ])->toArray(); + + $this->assertSame([ + 'name' => 'Freek', + 'company' => 'Spatie', + 'alt_name' => 'Freek from Spatie', + ], $transformed); + } + + public function testWhenUsingAdditionalMethodAndWithMethodTheAdditionalMethodWillBePrioritized(): void + { + $data = new class('Freek') extends Data { + /** + * Create a named data object. + */ + public function __construct(public string $name) + { + } + + /** + * Get the data appended to the output. + */ + public function with(): array + { + return [ + 'alt_name' => static function (self $data): string { + return $data->name . ' from Spatie via closure'; + }, + ]; + } + }; + + $this->assertSame([ + 'name' => 'Freek', + 'alt_name' => 'I m Freek from additional', + ], $data->additional(['alt_name' => 'I m Freek from additional'])->toArray()); + } +} diff --git a/tests/Data/Attributes/AttributeTest.php b/tests/Data/Attributes/AttributeTest.php index d365d02a26..d033320749 100644 --- a/tests/Data/Attributes/AttributeTest.php +++ b/tests/Data/Attributes/AttributeTest.php @@ -18,7 +18,6 @@ use Hypervel\Data\Exceptions\CannotCreateCastAttribute; use Hypervel\Data\Exceptions\CannotCreateTransformerAttribute; use Hypervel\Data\Exceptions\CannotFindDataClass; -use Hypervel\Data\Support\Creation\ConstructionState; use Hypervel\Data\Support\Creation\CreationContext; use Hypervel\Data\Support\DataProperty; use Hypervel\Data\Support\Transformation\TransformationContext; @@ -102,7 +101,7 @@ public function __construct(mixed ...$arguments) public function cast( DataProperty $property, mixed $value, - ConstructionState $state, + array $properties, CreationContext $context, ): mixed { return $value; diff --git a/tests/Data/Attributes/FromAuthenticatedUserPropertyTest.php b/tests/Data/Attributes/FromAuthenticatedUserPropertyTest.php new file mode 100644 index 0000000000..85047ae107 --- /dev/null +++ b/tests/Data/Attributes/FromAuthenticatedUserPropertyTest.php @@ -0,0 +1,44 @@ +actingAs((new User)->forceFill(['property' => 'value'])); + + $dataClass = new class('') extends Data { + /** + * Create the data object from a property of the current user. + */ + public function __construct( + #[CurrentUser(property: 'property')] + public string $value, + ) { + } + }; + + $this->assertSame('value', $dataClass::from()->value); + } +} diff --git a/tests/Data/Attributes/FromAuthenticatedUserTest.php b/tests/Data/Attributes/FromAuthenticatedUserTest.php new file mode 100644 index 0000000000..b550c29c13 --- /dev/null +++ b/tests/Data/Attributes/FromAuthenticatedUserTest.php @@ -0,0 +1,100 @@ +make('config')->set('auth.guards.other', ['driver' => 'session', 'provider' => 'users']); + } + + public function testCanGetTheCurrentLoggedInUser(): void + { + $this->actingAs($user = new User); + + $dataClass = new class(null) extends Data { + /** + * Create the data object from the current user. + */ + public function __construct( + #[CurrentUser] + public ?Authenticatable $user, + ) { + } + }; + + $this->assertSame($user, $dataClass::from()->user); + } + + public function testCanGetTheCurrentLoggedInUserUsingAnotherGuard(): void + { + $this->app->make('auth')->guard('other')->setUser($user = new User); + + $defaultGuard = new class(null) extends Data { + /** + * Create the data object from the default guard's user. + */ + public function __construct( + #[CurrentUser] + public ?Authenticatable $user, + ) { + } + }; + + $this->assertNull($defaultGuard::from()->user); + + $otherGuard = new class(null) extends Data { + /** + * Create the data object from the other guard's user. + */ + public function __construct( + #[CurrentUser('other')] + public ?Authenticatable $user, + ) { + } + }; + + $this->assertSame($user, $otherGuard::from()->user); + } + + public function testWillNotSetTheUserPropertyWhenNotLoggedIn(): void + { + // Upstream leaves the property unset; a contextual value always wins, so a guest is null. + $dataClass = new class(null) extends Data { + /** + * Create the data object from the current user. + */ + public function __construct( + #[CurrentUser] + public ?Authenticatable $user, + ) { + } + }; + + $this->assertNull($dataClass::from()->user); + } +} diff --git a/tests/Data/Attributes/FromContainerPropertyTest.php b/tests/Data/Attributes/FromContainerPropertyTest.php new file mode 100644 index 0000000000..9e9e66dc49 --- /dev/null +++ b/tests/Data/Attributes/FromContainerPropertyTest.php @@ -0,0 +1,67 @@ +app->bind('test', fn (): array => [ + 'property' => 'defined', + ]); + + $dataClass = new class('') extends Data { + /** + * Create the data object from a container entry property. + */ + public function __construct( + #[Give('test', property: 'property')] + public string $value, + ) { + } + }; + + $this->assertSame('defined', $dataClass::from()->value); + } + + public function testThrowsAnExceptionWhenTryingToFillADependencyPropertyUsingAScalarValue(): void + { + $this->app->bind('test', fn (): string => 'not-valid'); + + $dataClass = new class('') extends Data { + /** + * Create the data object from a container entry property. + */ + public function __construct( + #[Give('test', property: 'property')] + public string $property, + ) { + } + }; + + $this->expectException(BindingResolutionException::class); + $this->expectExceptionMessageIsOrContains('Cannot extract property path [property] from scalar [string]'); + + $dataClass::from(); + } +} diff --git a/tests/Data/Attributes/FromContainerTest.php b/tests/Data/Attributes/FromContainerTest.php new file mode 100644 index 0000000000..fef40934c3 --- /dev/null +++ b/tests/Data/Attributes/FromContainerTest.php @@ -0,0 +1,97 @@ +app) extends Data { + /** + * Create the data object from the container. + */ + public function __construct( + #[Give(Container::class)] + public Container $container, + ) { + } + }; + + $this->assertSame($this->app, $dataClass::from()->container); + } + + public function testCanGetADependencyFromTheContainer(): void + { + $this->app->bind('test', fn (): string => 'test'); + + $dataClass = new class('') extends Data { + /** + * Create the data object from a container entry. + */ + public function __construct( + #[Give('test')] + public string $test, + ) { + } + }; + + $this->assertSame('test', $dataClass::from()->test); + } + + public function testCanGetADependencyFromTheContainerWithParameters(): void + { + $this->app->bind('test', fn (Container $app, array $parameters): string => $parameters['parameter']); + + $dataClass = new class('') extends Data { + /** + * Create the data object from a container entry built with parameters. + */ + public function __construct( + #[Give('test', ['parameter' => 'Hello World'])] + public string $test, + ) { + } + }; + + $this->assertSame('Hello World', $dataClass::from()->test); + } + + public function testWillNotSetAPropertyWhenTheDependencyIsNotFound(): void + { + $dataClass = new class('') extends Data { + /** + * Create the data object from a missing container entry. + */ + public function __construct( + #[Give('test')] + public string $test, + ) { + } + }; + + // Upstream leaves the property unset; the container's resolution error is not hidden here. + $this->expectException(BindingResolutionException::class); + + $dataClass::from(); + } +} diff --git a/tests/Data/Attributes/FromRouteParameterPropertyTest.php b/tests/Data/Attributes/FromRouteParameterPropertyTest.php new file mode 100644 index 0000000000..8e16da834f --- /dev/null +++ b/tests/Data/Attributes/FromRouteParameterPropertyTest.php @@ -0,0 +1,68 @@ +bindRouteParameters(['parameter' => ['test' => 'Hello World']]); + + $dataClass = new class('') extends Data { + /** + * Create the data object from a route parameter property. + */ + public function __construct( + #[RouteParameter('parameter', property: 'test')] + public string $property, + ) { + } + }; + + $this->assertSame('Hello World', $dataClass::from($request)->property); + } + + public function testThrowsAnExceptionWhenTryingToFillARouteParameterPropertyUsingAScalarValue(): void + { + $request = $this->bindRouteParameters(['parameter' => 'not-valid']); + + $dataClass = new class('') extends Data { + /** + * Create the data object from a route parameter property. + */ + public function __construct( + #[RouteParameter('parameter', property: 'test')] + public string $property, + ) { + } + }; + + $this->expectException(BindingResolutionException::class); + $this->expectExceptionMessageIsOrContains('Cannot extract property path [test] from scalar [string]'); + + $dataClass::from($request); + } +} diff --git a/tests/Data/Attributes/FromRouteParameterTest.php b/tests/Data/Attributes/FromRouteParameterTest.php new file mode 100644 index 0000000000..5e45a63d9c --- /dev/null +++ b/tests/Data/Attributes/FromRouteParameterTest.php @@ -0,0 +1,65 @@ +bindRouteParameters(['parameter' => 'test']); + + $dataClass = new class('') extends Data { + /** + * Create the data object from a route parameter. + */ + public function __construct( + #[RouteParameter('parameter')] + public string $property, + ) { + } + }; + + $this->assertSame('test', $dataClass::from($request)->property); + } + + public function testReadsTheCurrentRouteWhenThePayloadIsNotARequest(): void + { + // Upstream's 'wont replace a route parameter if the payload is not a request': it reads the route only from a + // request payload, while a contextual value comes from the current request and wins. + $this->bindRouteParameters(['parameter' => 'route']); + + $dataClass = new class('') extends Data { + /** + * Create the data object from a route parameter. + */ + public function __construct( + #[RouteParameter('parameter')] + public string $property, + ) { + } + }; + + $this->assertSame('route', $dataClass::from(['property' => 'payload'])->property); + } +} diff --git a/tests/Data/Attributes/Validation/ExistsTest.php b/tests/Data/Attributes/Validation/ExistsTest.php index 1010a79927..b4795553e2 100644 --- a/tests/Data/Attributes/Validation/ExistsTest.php +++ b/tests/Data/Attributes/Validation/ExistsTest.php @@ -12,7 +12,6 @@ use Hypervel\Data\Support\Validation\ValidationPath; use Hypervel\Tests\TestCase; use Hypervel\Validation\Rules\Exists as ExistsRule; -use InvalidArgumentException; use PHPUnit\Framework\Attributes\DataProvider; class ExistsTest extends TestCase @@ -114,17 +113,6 @@ public static function invalidResolvedParameters(): iterable 'Exists deletedAtColumn must resolve to a string.', ]; } - - /** - * Test invalid database constraints fail clearly. - */ - public function testRejectsInvalidDatabaseConstraint(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessageIs('Each where item must be a DatabaseConstraint or Closure'); - - (new Exists('users', where: ['invalid']))->getRule(ValidationPath::create()); - } } class ExistsExternalReference implements ExternalReference diff --git a/tests/Data/Attributes/Validation/PasswordTest.php b/tests/Data/Attributes/Validation/PasswordTest.php index 1ca7d3f99e..e7de68d82a 100644 --- a/tests/Data/Attributes/Validation/PasswordTest.php +++ b/tests/Data/Attributes/Validation/PasswordTest.php @@ -4,6 +4,7 @@ namespace Hypervel\Tests\Data\Attributes\Validation; +use Closure; use Hypervel\Data\Attributes\Validation\Password as PasswordAttribute; use Hypervel\Data\Exceptions\CannotBuildValidationRule; use Hypervel\Data\Support\Validation\References\ExternalReference; @@ -84,18 +85,37 @@ public function testBuildsConfiguredRuleFromExternalReferences(): void ], $rule->appliedRules()); } + #[DataProvider('preconfiguredPasswordValidators')] + public function testPasswordRuleReturnsPreconfiguredPasswordValidators(Closure $setDefaults, array $expectedConfig): void + { + $setDefaults(); + + $rule = (new PasswordAttribute(default: true))->getRule(ValidationPath::create()); + + foreach ($expectedConfig as $key => $expected) { + $this->assertSame($expected, $rule->appliedRules()[$key]); + } + } + /** - * Test the attribute uses the framework's default password rule. + * Provide framework password defaults and the configuration they give the default rule. */ - public function testUsesDefaultPasswordRule(): void + public static function preconfiguredPasswordValidators(): iterable { - PasswordRule::defaults(fn () => PasswordRule::min(42)->uncompromised(7)); + yield 'min length set to 42' => [ + static fn (): ?PasswordRule => PasswordRule::defaults(static fn (): PasswordRule => PasswordRule::min(42)), + ['min' => 42], + ]; - $rule = (new PasswordAttribute(default: true))->getRule(ValidationPath::create()); + yield 'unconfigured' => [ + static fn (): null => null, + ['min' => 8], + ]; - $this->assertSame(42, $rule->appliedRules()['min']); - $this->assertTrue($rule->appliedRules()['uncompromised']); - $this->assertSame(7, $rule->appliedRules()['compromisedThreshold']); + yield 'uncompromised' => [ + static fn (): ?PasswordRule => PasswordRule::defaults(static fn (): PasswordRule => PasswordRule::min(69)->uncompromised(7)), + ['min' => 69, 'uncompromised' => true, 'compromisedThreshold' => 7], + ]; } /** diff --git a/tests/Data/Attributes/Validation/RulesTest.php b/tests/Data/Attributes/Validation/RulesTest.php new file mode 100644 index 0000000000..51acfb3320 --- /dev/null +++ b/tests/Data/Attributes/Validation/RulesTest.php @@ -0,0 +1,134 @@ +expectException($exception); + } + + $resolved = (new RuleDenormalizer)->execute($attribute, ValidationPath::create()); + + $this->assertRuleEquals($expected, $resolved[0]); + } + + #[DataProvider('attributes')] + public function testCreatesTheCorrectAttributes( + ValidationRule $attribute, + string|object $expected, + ?ValidationRule $expectedCreatedAttribute, + ?string $exception = null, + ): void { + if ($exception) { + $this->assertTrue(true); + + return; + } + + $resolved = (new RuleNormalizer(new ValidationRuleFactory))->execute($expected); + + $this->assertRuleEquals($expectedCreatedAttribute, $resolved[0]); + } + + /** + * Provide each validation attribute with its compiled rule and the attribute rebuilt from that rule. + */ + public static function attributes(): iterable + { + return RulesDataset\attributes(); + } + + public function testCanUseTheRuleRule(): void + { + $rule = new Rule( + 'test', + ['a', 'b', 'c'], + 'x|y', + new CustomHypervelRule, + new Required + ); + + $this->assertEquals( + ['test', 'a', 'b', 'c', 'x', 'y', new CustomHypervelRule, 'required'], + (new RuleDenormalizer)->execute($rule, ValidationPath::create()), + ); + } + + // REMOVED: "can use the Rule rule with invokable rules"; Laravel's deprecated InvokableRule contract is not included. + + public function testCanUseTheRuleRuleWithValidationRuleContract(): void + { + $rule = new Rule( + 'test', + ['a', 'b', 'c'], + 'x|y', + new CustomHypervelValidationRule, + new Required + ); + + $this->assertEquals( + ['test', 'a', 'b', 'c', 'x', 'y', new CustomHypervelValidationRule, 'required'], + (new RuleDenormalizer)->execute($rule, ValidationPath::create()), + ); + } + + /** + * Assert two rules are equal, comparing database query callbacks by identity, since closures cannot be compared. + */ + protected function assertRuleEquals(mixed $expected, mixed $actual): void + { + if ($expected instanceof Exists || $expected instanceof Unique) { + $this->assertInstanceOf($expected::class, $actual); + + $expected = $expected->getRule(ValidationPath::create()); + $actual = $actual->getRule(ValidationPath::create()); + } + + if ($expected instanceof BaseExists || $expected instanceof BaseUnique) { + $this->assertInstanceOf($expected::class, $actual); + $this->assertSame((string) $expected, (string) $actual); + $this->assertSame($expected->queryCallbacks(), $actual->queryCallbacks()); + + return; + } + + $this->assertEquals($expected, $actual); + } +} diff --git a/tests/Data/Attributes/Validation/UniqueTest.php b/tests/Data/Attributes/Validation/UniqueTest.php index baa49149d2..6ea5236893 100644 --- a/tests/Data/Attributes/Validation/UniqueTest.php +++ b/tests/Data/Attributes/Validation/UniqueTest.php @@ -12,7 +12,6 @@ use Hypervel\Data\Support\Validation\ValidationPath; use Hypervel\Tests\TestCase; use Hypervel\Validation\Rules\Unique as UniqueRule; -use InvalidArgumentException; use PHPUnit\Framework\Attributes\DataProvider; class UniqueTest extends TestCase @@ -133,17 +132,6 @@ public static function invalidResolvedParameters(): iterable 'Unique deletedAtColumn must resolve to a string.', ]; } - - /** - * Test invalid database constraints fail clearly. - */ - public function testRejectsInvalidDatabaseConstraint(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessageIs('Each where item must be a DatabaseConstraint or Closure'); - - (new Unique('users', where: ['invalid']))->getRule(ValidationPath::create()); - } } class UniqueExternalReference implements ExternalReference diff --git a/tests/Data/Attributes/Validation/ValidationAttributeTest.php b/tests/Data/Attributes/Validation/ValidationAttributeTest.php index 8a58d75cc7..5f70b94930 100644 --- a/tests/Data/Attributes/Validation/ValidationAttributeTest.php +++ b/tests/Data/Attributes/Validation/ValidationAttributeTest.php @@ -6,36 +6,16 @@ use DateTimeZone; use Egulias\EmailValidator\Validation\RFCValidation; -use Hypervel\Data\Attributes\Validation\Accepted; -use Hypervel\Data\Attributes\Validation\AcceptedIf; -use Hypervel\Data\Attributes\Validation\ActiveUrl; -use Hypervel\Data\Attributes\Validation\After; -use Hypervel\Data\Attributes\Validation\AfterOrEqual; -use Hypervel\Data\Attributes\Validation\Alpha; -use Hypervel\Data\Attributes\Validation\AlphaDash; -use Hypervel\Data\Attributes\Validation\AlphaNumeric; use Hypervel\Data\Attributes\Validation\AnyOf; use Hypervel\Data\Attributes\Validation\ArrayType; use Hypervel\Data\Attributes\Validation\Ascii; -use Hypervel\Data\Attributes\Validation\Bail; use Hypervel\Data\Attributes\Validation\Base64; -use Hypervel\Data\Attributes\Validation\Before; -use Hypervel\Data\Attributes\Validation\BeforeOrEqual; -use Hypervel\Data\Attributes\Validation\Between; -use Hypervel\Data\Attributes\Validation\BooleanType; use Hypervel\Data\Attributes\Validation\Can; -use Hypervel\Data\Attributes\Validation\Confirmed; use Hypervel\Data\Attributes\Validation\Contains; use Hypervel\Data\Attributes\Validation\CurrentPassword; -use Hypervel\Data\Attributes\Validation\Date; -use Hypervel\Data\Attributes\Validation\DateEquals; use Hypervel\Data\Attributes\Validation\DateFormat; use Hypervel\Data\Attributes\Validation\Decimal; -use Hypervel\Data\Attributes\Validation\Declined; use Hypervel\Data\Attributes\Validation\DeclinedIf; -use Hypervel\Data\Attributes\Validation\Different; -use Hypervel\Data\Attributes\Validation\Digits; -use Hypervel\Data\Attributes\Validation\DigitsBetween; use Hypervel\Data\Attributes\Validation\Dimensions; use Hypervel\Data\Attributes\Validation\Distinct; use Hypervel\Data\Attributes\Validation\DoesntContain; @@ -47,34 +27,15 @@ use Hypervel\Data\Attributes\Validation\Enum; use Hypervel\Data\Attributes\Validation\Exclude; use Hypervel\Data\Attributes\Validation\ExcludeIf; -use Hypervel\Data\Attributes\Validation\ExcludeUnless; -use Hypervel\Data\Attributes\Validation\ExcludeWith; -use Hypervel\Data\Attributes\Validation\ExcludeWithout; use Hypervel\Data\Attributes\Validation\Extensions; -use Hypervel\Data\Attributes\Validation\File; -use Hypervel\Data\Attributes\Validation\Filled; use Hypervel\Data\Attributes\Validation\GreaterThan; -use Hypervel\Data\Attributes\Validation\GreaterThanOrEqualTo; use Hypervel\Data\Attributes\Validation\HexColor; -use Hypervel\Data\Attributes\Validation\Image; use Hypervel\Data\Attributes\Validation\InArray; use Hypervel\Data\Attributes\Validation\InArrayKeys; -use Hypervel\Data\Attributes\Validation\IntegerType; -use Hypervel\Data\Attributes\Validation\IP; -use Hypervel\Data\Attributes\Validation\IPv4; -use Hypervel\Data\Attributes\Validation\IPv6; -use Hypervel\Data\Attributes\Validation\Json; use Hypervel\Data\Attributes\Validation\LessThan; -use Hypervel\Data\Attributes\Validation\LessThanOrEqualTo; -use Hypervel\Data\Attributes\Validation\ListType; -use Hypervel\Data\Attributes\Validation\Lowercase; -use Hypervel\Data\Attributes\Validation\MacAddress; use Hypervel\Data\Attributes\Validation\Max; -use Hypervel\Data\Attributes\Validation\MaxDigits; use Hypervel\Data\Attributes\Validation\Mimes; use Hypervel\Data\Attributes\Validation\MimeTypes; -use Hypervel\Data\Attributes\Validation\Min; -use Hypervel\Data\Attributes\Validation\MinDigits; use Hypervel\Data\Attributes\Validation\Missing; use Hypervel\Data\Attributes\Validation\MissingIf; use Hypervel\Data\Attributes\Validation\MissingUnless; @@ -82,9 +43,6 @@ use Hypervel\Data\Attributes\Validation\MissingWithAll; use Hypervel\Data\Attributes\Validation\MultipleOf; use Hypervel\Data\Attributes\Validation\NotRegex; -use Hypervel\Data\Attributes\Validation\Nullable; -use Hypervel\Data\Attributes\Validation\Numeric; -use Hypervel\Data\Attributes\Validation\Present; use Hypervel\Data\Attributes\Validation\PresentIf; use Hypervel\Data\Attributes\Validation\PresentUnless; use Hypervel\Data\Attributes\Validation\PresentWith; @@ -107,21 +65,17 @@ use Hypervel\Data\Attributes\Validation\RequiredWithout; use Hypervel\Data\Attributes\Validation\RequiredWithoutAll; use Hypervel\Data\Attributes\Validation\Rule as RuleAttribute; -use Hypervel\Data\Attributes\Validation\Same; use Hypervel\Data\Attributes\Validation\Size; -use Hypervel\Data\Attributes\Validation\Sometimes; use Hypervel\Data\Attributes\Validation\StartsWith; use Hypervel\Data\Attributes\Validation\StringType; use Hypervel\Data\Attributes\Validation\StringValidationAttribute; -use Hypervel\Data\Attributes\Validation\Timezone; -use Hypervel\Data\Attributes\Validation\Ulid; -use Hypervel\Data\Attributes\Validation\Uppercase; use Hypervel\Data\Attributes\Validation\Url; -use Hypervel\Data\Attributes\Validation\Uuid; use Hypervel\Data\Exceptions\CannotBuildValidationRule; use Hypervel\Data\Support\Validation\References\ExternalReference; use Hypervel\Data\Support\Validation\RuleDenormalizer; +use Hypervel\Data\Support\Validation\RuleNormalizer; use Hypervel\Data\Support\Validation\ValidationPath; +use Hypervel\Data\Support\Validation\ValidationRuleFactory; use Hypervel\Support\CarbonImmutable; use Hypervel\Tests\TestCase; use Hypervel\Translation\ArrayLoader; @@ -133,10 +87,8 @@ use Hypervel\Validation\Rules\ExcludeIf as ExcludeIfRule; use Hypervel\Validation\Rules\ProhibitedIf as ProhibitedIfRule; use Hypervel\Validation\Rules\RequiredIf as RequiredIfRule; -use Hypervel\Validation\ValidationRuleParser; use Hypervel\Validation\Validator; use PHPUnit\Framework\Attributes\DataProvider; -use ReflectionMethod; use ReflectionProperty; class ValidationAttributeTest extends TestCase @@ -144,13 +96,13 @@ class ValidationAttributeTest extends TestCase /** * Test rules expose their Validator string representation. */ - public function testGetsAStringRepresentationOfRules(): void + public function testCanGetAStringRepresentationOfRules(): void { $this->assertSame('string', (string) new StringType); } #[DataProvider('normalizedValues')] - public function testNormalizesValues(mixed $input, string $output, ?string $key = null): void + public function testCanNormalizeValues(mixed $input, string $output, ?string $key = null): void { $attribute = new class($key === null ? [$input] : [$key => $input]) extends StringValidationAttribute { /** @@ -254,75 +206,35 @@ public static function literalParameterRules(): iterable } /** - * Test simple attributes compile from objects and parsed string parameters. + * Test Hypervel's additional attributes and parameter forms compile, and their rule strings rebuild them losslessly. */ #[DataProvider('stringRules')] public function testCompilesStringValidationAttributes( StringValidationAttribute $attribute, string $expected, ): void { - $rules = (new RuleDenormalizer)->execute( - $attribute, - ValidationPath::create(), - ); - - $this->assertSame([$expected], $rules); + $denormalizer = new RuleDenormalizer; - [, $parameters] = ValidationRuleParser::parse($expected); - $createdAttribute = $attribute::create(...$parameters); + $this->assertSame([$expected], $denormalizer->execute($attribute, ValidationPath::create())); - if ((new ReflectionMethod($attribute, 'create'))->getDeclaringClass()->getName() !== StringValidationAttribute::class) { - return; - } + $normalized = (new RuleNormalizer(new ValidationRuleFactory))->execute($expected); - $this->assertSame( - [$expected], - (new RuleDenormalizer)->execute($createdAttribute, ValidationPath::create()), - ); + $this->assertCount(1, $normalized); + $this->assertNotInstanceOf(RuleAttribute::class, $normalized[0]); + $this->assertSame([$expected], $denormalizer->execute($normalized, ValidationPath::create())); } /** - * Provide simple string validation attributes. + * Provide the attributes and parameter forms RulesTest's upstream dataset does not cover. */ public static function stringRules(): iterable { - yield [new Accepted, 'accepted']; - yield [new AcceptedIf('status', true), 'accepted_if:status,true']; - yield [new ActiveUrl, 'active_url']; - yield [new After('tomorrow'), 'after:tomorrow']; - yield [new AfterOrEqual('tomorrow'), 'after_or_equal:tomorrow']; - yield [new Alpha, 'alpha']; - yield [new AlphaDash, 'alpha_dash']; - yield [new AlphaNumeric, 'alpha_num']; - yield [new ArrayType(['name', 'email']), 'array:name,email']; yield [new Ascii, 'ascii']; - yield [new Bail, 'bail']; yield [new Base64, 'base64']; - yield [new Before('tomorrow'), 'before:tomorrow']; - yield [new BeforeOrEqual('tomorrow'), 'before_or_equal:tomorrow']; - yield [new Between(1, 10), 'between:1,10']; - yield [new BooleanType, 'boolean']; - yield [new Confirmed, 'confirmed']; yield [new Contains(['admin', [42]], new ValidationAttributeExternalReference('member')), 'contains:admin,42,member']; - yield [new CurrentPassword, 'current_password']; - yield [new CurrentPassword('api'), 'current_password:api']; - yield [new CurrentPassword(ValidationAttributeBackedEnum::Foo), 'current_password:foo']; yield [new CurrentPassword(new ValidationAttributeExternalReference), 'current_password:admin']; - yield [CurrentPassword::create('api'), 'current_password:api']; - yield [new Date, 'date']; - yield [new DateEquals('tomorrow'), 'date_equals:tomorrow']; - yield [new DateFormat('Y-m-d'), 'date_format:Y-m-d']; - yield [new DateFormat(['Y-m-d', 'Y-m-d H:i:s']), 'date_format:Y-m-d,Y-m-d H:i:s']; - yield [new DateFormat('Y-m-d', 'Y-m-d H:i:s'), 'date_format:Y-m-d,Y-m-d H:i:s']; yield [new Decimal('2', '4'), 'decimal:2,4']; - yield [new Declined, 'declined']; yield [new DeclinedIf('status', false), 'declined_if:status,false']; - yield [new Different('password'), 'different:password']; - yield [new Digits(4), 'digits:4']; - yield [new DigitsBetween(2, 6), 'digits_between:2,6']; - yield [new Distinct, 'distinct']; - yield [new Distinct(Distinct::Strict), 'distinct:strict']; - yield [new Distinct(Distinct::IgnoreCase), 'distinct:ignore_case']; yield [new Distinct(new ValidationAttributeExternalReference(Distinct::Strict)), 'distinct:strict']; yield [new Distinct(new ValidationAttributeExternalReference(null)), 'distinct']; yield [ @@ -337,7 +249,6 @@ public static function stringRules(): iterable new DoesntStartWith(['admin', ['root']], new ValidationAttributeExternalReference('system')), 'doesnt_start_with:admin,root,system', ]; - yield [new Email, 'email:rfc']; yield [ new Email(Email::DnsCheckValidation, Email::FilterUnicodeEmailValidation), 'email:dns,filter_unicode', @@ -350,52 +261,24 @@ public static function stringRules(): iterable 'ends_with:.json,.yaml,.yml', ]; yield [new ExcludeIf('status', false), 'exclude_if:status,false']; - yield [new ExcludeUnless('status', 'published'), 'exclude_unless:status,published']; - yield [new ExcludeWith('archived_at'), 'exclude_with:archived_at']; - yield [new ExcludeWithout('published_at'), 'exclude_without:published_at']; yield [new Extensions(['jpg', ['png']], new ValidationAttributeExternalReference('webp')), 'extensions:jpg,png,webp']; - yield [new File, 'file']; - yield [new Filled, 'filled']; - yield [new GreaterThan('other'), 'gt:other']; - yield [new GreaterThan(10), 'gt:10']; yield [new GreaterThan('99999999999999999999'), 'gt:99999999999999999999']; - yield [new GreaterThanOrEqualTo('other'), 'gte:other']; - yield [new GreaterThanOrEqualTo('10'), 'gte:10']; yield [new HexColor, 'hex_color']; - yield [new IP, 'ip']; - yield [new IPv4, 'ipv4']; - yield [new IPv6, 'ipv6']; - yield [new Image, 'image']; yield [new InArray('roles.*'), 'in_array:roles.*']; yield [new InArrayKeys(['name', [42]], new ValidationAttributeExternalReference('email')), 'in_array_keys:name,42,email']; - yield [new IntegerType, 'integer']; - yield [new Json, 'json']; - yield [new LessThan('other'), 'lt:other']; yield [new LessThan('10.50'), 'lt:10.50']; - yield [new LessThanOrEqualTo('other'), 'lte:other']; - yield [new LessThanOrEqualTo(10), 'lte:10']; - yield [new ListType, 'list']; - yield [new Lowercase, 'lowercase']; - yield [new MacAddress, 'mac_address']; yield [new Max('99999999999999999999'), 'max:99999999999999999999']; - yield [new MaxDigits(10), 'max_digits:10']; yield [ new MimeTypes(['image/jpeg', ['image/png']], new ValidationAttributeExternalReference('image/webp')), 'mimetypes:image/jpeg,image/png,image/webp', ]; yield [new Mimes(['jpg', ['png']], new ValidationAttributeExternalReference('webp')), 'mimes:jpg,png,webp']; - yield [new Min(1.5), 'min:1.5']; - yield [new MinDigits(2), 'min_digits:2']; yield [new Missing, 'missing']; yield [new MissingIf('status', true, null), 'missing_if:status,true,null']; yield [new MissingUnless('status', 1, 2.5), 'missing_unless:status,1,2.5']; yield [new MissingWith(['email', ['phone']]), 'missing_with:email,phone']; yield [new MissingWithAll(['email', ['phone']]), 'missing_with_all:email,phone']; yield [new MultipleOf('0.000000000000000001'), 'multiple_of:0.000000000000000001']; - yield [new NotRegex('/foo/'), 'not_regex:/foo/']; - yield [new Nullable, 'nullable']; - yield [new Numeric, 'numeric']; - yield [new Present, 'present']; yield [new PresentIf('status', true, null), 'present_if:status,true,null']; yield [new PresentUnless('status', 1, 2.5), 'present_unless:status,1,2.5']; yield [new PresentWith(['email', ['phone']]), 'present_with:email,phone']; @@ -413,7 +296,6 @@ public static function stringRules(): iterable ]; yield [new ProhibitedUnless('count', 1, 2.5), 'prohibited_unless:count,1,2.5']; yield [new Prohibits(['email', ['phone']]), 'prohibits:email,phone']; - yield [new Regex('/foo/'), 'regex:/foo/']; yield [ new RequiredArrayKeys(['name', ['email']], new ValidationAttributeExternalReference('role')), 'required_array_keys:name,email,role', @@ -434,18 +316,12 @@ public static function stringRules(): iterable yield [new RequiredWithAll(['email', ['phone']]), 'required_with_all:email,phone']; yield [new RequiredWithout(['email', ['phone']]), 'required_without:email,phone']; yield [new RequiredWithoutAll(['email', ['phone']]), 'required_without_all:email,phone']; - yield [new Same('password'), 'same:password']; yield [new Size('99999999999999999999'), 'size:99999999999999999999']; - yield [new Sometimes, 'sometimes']; yield [ new StartsWith(['admin', ['root']], new ValidationAttributeExternalReference('system')), 'starts_with:admin,root,system', ]; - yield [new Timezone, 'timezone']; - yield [new Ulid, 'ulid']; - yield [new Uppercase, 'uppercase']; yield [new Url(['http', ['https']], new ValidationAttributeExternalReference('ftp')), 'url:http,https,ftp']; - yield [new Uuid, 'uuid']; } /** @@ -494,21 +370,6 @@ public function testCannotCreateCanFromStringParameters(): void Can::create(); } - /** - * Test dimensions attributes compile to native rule objects. - */ - public function testCompilesDimensionsAttributes(): void - { - $rules = (new RuleDenormalizer)->execute( - new Dimensions(minWidth: 15, maxHeight: 100, ratio: 1.5), - ValidationPath::create(), - ); - - $this->assertCount(1, $rules); - $this->assertInstanceOf(DimensionsRule::class, $rules[0]); - $this->assertSame('dimensions:min_width=15,max_height=100,ratio=1.5', (string) $rules[0]); - } - /** * Test dimensions attributes retain an explicitly supplied rule. */ @@ -523,19 +384,6 @@ public function testUsesProvidedDimensionsRule(): void $this->assertSame([$rule], $rules); } - /** - * Test parsed dimensions parameters use named constraints. - */ - public function testCreatesDimensionsAttributeFromParameters(): void - { - $rules = (new RuleDenormalizer)->execute( - Dimensions::create('min_width=15', 'max_height=100', 'ratio=1.5'), - ValidationPath::create(), - ); - - $this->assertSame('dimensions:min_width=15,max_height=100,ratio=1.5', (string) $rules[0]); - } - /** * Test enum attributes compile to configured native rule objects. */ @@ -616,19 +464,6 @@ public function testCompilesRequiredAttributes(): void $this->assertSame(['required'], $denormalizer->execute(Required::create(), $path)); } - /** - * Test rule attributes flatten wrapped rule declarations. - */ - public function testCompilesWrappedRuleAttributes(): void - { - $rules = (new RuleDenormalizer)->execute( - new RuleAttribute('string|required', new Min(3), ['nullable']), - ValidationPath::create(), - ); - - $this->assertSame(['string', 'required', 'min:3', 'nullable'], $rules); - } - /** * Test empty dimensions declarations fail clearly. */ @@ -663,11 +498,10 @@ public function testRejectsInvalidEmailMode(string|ExternalReference $mode): voi } /** - * Provide unsupported email modes. + * Provide unsupported email modes; RulesTest covers a literal unsupported mode. */ public static function invalidEmailModes(): iterable { - yield ['unsupported']; yield [new ValidationAttributeExternalReference(['rfc', 'dns'])]; } diff --git a/tests/Data/CapabilityTest.php b/tests/Data/CapabilityTest.php deleted file mode 100644 index ff5b75302a..0000000000 --- a/tests/Data/CapabilityTest.php +++ /dev/null @@ -1,58 +0,0 @@ -assertTrue(is_a(Data::class, TransformableData::class, true)); - $this->assertTrue(is_a(Data::class, Castable::class, true)); - $this->assertTrue(is_a(Data::class, RequestCastable::class, true)); - $this->assertTrue(is_a(Data::class, ResponsableData::class, true)); - $this->assertTrue(is_a(Resource::class, TransformableData::class, true)); - $this->assertTrue(is_a(Resource::class, Castable::class, true)); - $this->assertTrue(is_a(Resource::class, RequestCastable::class, true)); - $this->assertTrue(is_a(Resource::class, ResponsableData::class, true)); - $this->assertFalse(is_a(Dto::class, TransformableData::class, true)); - $this->assertFalse(is_a(Dto::class, Castable::class, true)); - $this->assertTrue(is_a(Dto::class, RequestCastable::class, true)); - $this->assertFalse(is_a(Dto::class, ResponsableData::class, true)); - } - - /** - * Test paginator wrappers remain transformable without claiming persistence support. - */ - public function testCollectionCapabilities(): void - { - $this->assertTrue(is_a(DataCollection::class, TransformableData::class, true)); - $this->assertTrue(is_a(DataCollection::class, Castable::class, true)); - $this->assertFalse(is_a(DataCollection::class, RequestCastable::class, true)); - $this->assertTrue(is_a(DataCollection::class, ResponsableData::class, true)); - $this->assertTrue(is_a(PaginatedDataCollection::class, TransformableData::class, true)); - $this->assertFalse(is_a(PaginatedDataCollection::class, Castable::class, true)); - $this->assertFalse(is_a(PaginatedDataCollection::class, RequestCastable::class, true)); - $this->assertTrue(is_a(PaginatedDataCollection::class, ResponsableData::class, true)); - $this->assertTrue(is_a(CursorPaginatedDataCollection::class, TransformableData::class, true)); - $this->assertFalse(is_a(CursorPaginatedDataCollection::class, Castable::class, true)); - $this->assertFalse(is_a(CursorPaginatedDataCollection::class, RequestCastable::class, true)); - $this->assertTrue(is_a(CursorPaginatedDataCollection::class, ResponsableData::class, true)); - } -} diff --git a/tests/Data/Casts/BuiltinTypeCastTest.php b/tests/Data/Casts/BuiltinTypeCastTest.php index 7474c8aef8..3537e99d9a 100644 --- a/tests/Data/Casts/BuiltinTypeCastTest.php +++ b/tests/Data/Casts/BuiltinTypeCastTest.php @@ -6,27 +6,23 @@ use Hypervel\Data\Casts\BuiltinTypeCast; use Hypervel\Data\Contracts\BaseData; -use Hypervel\Data\Support\Creation\ConstructionState; use Hypervel\Data\Support\Creation\CreationContext; use Hypervel\Data\Support\DataProperty; use Hypervel\Tests\TestCase; use PHPUnit\Framework\Attributes\DataProvider; +use TypeError; class BuiltinTypeCastTest extends TestCase { - /** - * Test built-in scalar and array casting. - */ #[DataProvider('castProvider')] - public function testCastsBuiltInValues(string $type, mixed $value, mixed $expected): void + public function testCanCastBuiltinTypes(string $type, mixed $value, mixed $expected): void { $context = new CreationContext(BuiltinTypeCastDataFixture::class); - $state = ConstructionState::create($context, BuiltinTypeCastDataFixture::class); $property = $this->createStub(DataProperty::class); $cast = new BuiltinTypeCast($type); - $this->assertSame($expected, $cast->cast($property, $value, $state, $context)); - $this->assertSame($expected, $cast->castIterableItem($property, $value, $state, $context)); + $this->assertSame($expected, $cast->cast($property, $value, [], $context)); + $this->assertSame($expected, $cast->castIterableItem($property, $value, [], $context)); } /** @@ -35,16 +31,76 @@ public function testCastsBuiltInValues(string $type, mixed $value, mixed $expect public static function castProvider(): array { return [ - 'true string' => ['bool', 'TRUE', true], - 'false string' => ['bool', 'False', false], - 'zero string' => ['bool', '0', false], - 'truthy string' => ['bool', 'yes', true], - 'integer' => ['int', '42', 42], - 'float' => ['float', '4.2', 4.2], - 'array' => ['array', 'value', ['value']], - 'string' => ['string', 42, '42'], + 'string "true" to bool' => ['bool', 'true', true], + 'string "false" to bool' => ['bool', 'false', false], + 'string "TRUE" (uppercase) to bool' => ['bool', 'TRUE', true], + 'string "FALSE" (uppercase) to bool' => ['bool', 'FALSE', false], + 'mixed case string "TrUe" to bool' => ['bool', 'TrUe', true], + 'integer 1 to bool true' => ['bool', 1, true], + 'integer 0 to bool false' => ['bool', 0, false], + 'non-empty string to bool true' => ['bool', 'some text', true], + 'empty string to bool false' => ['bool', '', false], + 'string "0" to bool false' => ['bool', '0', false], + 'string "1" to bool true' => ['bool', '1', true], + 'int types' => ['int', '42', 42], + 'float types' => ['float', '42.5', 42.5], + 'string types' => ['string', 42, '42'], + 'array types' => ['array', (object) ['key' => 'value'], ['key' => 'value']], + 'scalar to array' => ['array', 'value', ['value']], + 'numeric string with whitespace and exponent to int' => ['int', ' 1e3 ', 1000], + 'boolean to int' => ['int', true, 1], + 'stringable to string' => ['string', new BuiltinTypeCastStringable, 'stringable'], ]; } + + #[DataProvider('invalidValueProvider')] + public function testRejectsValuesPhpWouldNotConvert(string $type, mixed $value): void + { + $context = new CreationContext(BuiltinTypeCastDataFixture::class); + $property = $this->createStub(DataProperty::class); + $cast = new BuiltinTypeCast($type); + + $rejected = 0; + + foreach ([$cast->cast(...), $cast->castIterableItem(...)] as $convert) { + try { + $convert($property, $value, [], $context); + } catch (TypeError) { + ++$rejected; + } + } + + $this->assertSame(2, $rejected); + } + + /** + * Provide values PHP's weak typing rejects for the target type. + */ + public static function invalidValueProvider(): array + { + return [ + 'non-numeric string to int' => ['int', 'abc'], + 'leading-numeric string to int' => ['int', '12abc'], + 'empty string to int' => ['int', ''], + 'not-a-number to int' => ['int', NAN], + 'non-numeric string to float' => ['float', 'x'], + 'array to int' => ['int', []], + 'array to string' => ['string', ['value']], + 'array to bool' => ['bool', []], + 'object to bool' => ['bool', new BuiltinTypeCastStringable], + ]; + } +} + +class BuiltinTypeCastStringable +{ + /** + * Get the string form. + */ + public function __toString(): string + { + return 'stringable'; + } } abstract class BuiltinTypeCastDataFixture implements BaseData diff --git a/tests/Data/Casts/DateTimeInterfaceCastTest.php b/tests/Data/Casts/DateTimeInterfaceCastTest.php index fde44d4ed4..c9e7539f47 100644 --- a/tests/Data/Casts/DateTimeInterfaceCastTest.php +++ b/tests/Data/Casts/DateTimeInterfaceCastTest.php @@ -6,17 +6,22 @@ use Carbon\Carbon; use Carbon\CarbonImmutable; +use Carbon\CarbonTimeZone; use DateTime; use DateTimeImmutable; use DateTimeInterface; +use DateTimeZone; use Hypervel\Config\Repository; use Hypervel\Container\Container; +use Hypervel\Contracts\Foundation\Application; +use Hypervel\Data\Attributes\WithCast; use Hypervel\Data\Casts\DateTimeInterfaceCast; use Hypervel\Data\Casts\Uncastable; use Hypervel\Data\Contracts\BaseData; +use Hypervel\Data\Data; +use Hypervel\Data\DataServiceProvider; use Hypervel\Data\Exceptions\CannotCastDate; use Hypervel\Data\Support\Annotations\DataIterableAnnotationReader; -use Hypervel\Data\Support\Creation\ConstructionState; use Hypervel\Data\Support\Creation\CreationContext; use Hypervel\Data\Support\DataConfig; use Hypervel\Data\Support\DataProperty; @@ -26,17 +31,167 @@ use Hypervel\Data\Support\Types\PhpDocTypeNameResolver; use Hypervel\Support\Carbon as HypervelCarbon; use Hypervel\Support\CarbonImmutable as HypervelCarbonImmutable; -use Hypervel\Tests\TestCase; +use Hypervel\Testbench\TestCase; +use PHPUnit\Framework\Attributes\DataProvider; use ReflectionClass; class DateTimeInterfaceCastTest extends TestCase { + /** + * Get package providers for the test application. + */ + protected function getPackageProviders(Application $app): array + { + return [DataServiceProvider::class]; + } + + public function testCanCastDateTimes(): void + { + [$properties, $context] = $this->operation([DATE_ATOM]); + $cast = new DateTimeInterfaceCast('d-m-Y H:i:s'); + + $this->assertEquals(new Carbon('19-05-1994 00:00:00'), $cast->cast($this->property('carbon'), '19-05-1994 00:00:00', $properties, $context)); + $this->assertEquals(new CarbonImmutable('19-05-1994 00:00:00'), $cast->cast($this->property('carbonImmutable'), '19-05-1994 00:00:00', $properties, $context)); + $this->assertEquals(new DateTime('19-05-1994 00:00:00'), $cast->cast($this->property('mutable'), '19-05-1994 00:00:00', $properties, $context)); + $this->assertEquals(new DateTimeImmutable('19-05-1994 00:00:00'), $cast->cast($this->property('immutable'), '19-05-1994 00:00:00', $properties, $context)); + + $this->assertEquals(new Carbon('19-05-1994 00:00:00'), $cast->cast($this->property('carbon'), new Carbon('19-05-1994 00:00:00'), $properties, $context)); + $this->assertEquals(new CarbonImmutable('19-05-1994 00:00:00'), $cast->cast($this->property('carbonImmutable'), new CarbonImmutable('19-05-1994 00:00:00'), $properties, $context)); + $this->assertEquals(new DateTime('19-05-1994 00:00:00'), $cast->cast($this->property('mutable'), new DateTime('19-05-1994 00:00:00'), $properties, $context)); + $this->assertEquals(new DateTimeImmutable('19-05-1994 00:00:00'), $cast->cast($this->property('immutable'), new DateTimeImmutable('19-05-1994 00:00:00'), $properties, $context)); + + $this->assertEquals(new Carbon('19-05-1994 00:00:00'), $cast->cast($this->property('carbon'), new DateTime('19-05-1994 00:00:00'), $properties, $context)); + $this->assertEquals(new CarbonImmutable('19-05-1994 00:00:00'), $cast->cast($this->property('carbonImmutable'), new DateTimeImmutable('19-05-1994 00:00:00'), $properties, $context)); + $this->assertEquals(new DateTime('19-05-1994 00:00:00'), $cast->cast($this->property('mutable'), new Carbon('19-05-1994 00:00:00'), $properties, $context)); + $this->assertEquals(new DateTimeImmutable('19-05-1994 00:00:00'), $cast->cast($this->property('immutable'), new CarbonImmutable('19-05-1994 00:00:00'), $properties, $context)); + } + + public function testFailsWhenItCannotCastADateIntoTheCorrectFormat(): void + { + [$properties, $context] = $this->operation([DATE_ATOM]); + + $this->expectException(CannotCastDate::class); + $this->expectExceptionMessageIsOrContains(DateTime::class); + $this->expectExceptionMessageMatches('/d-m-Y H:i:s/'); + + (new DateTimeInterfaceCast('d-m-Y H:i:s'))->cast( + $this->property('mutable'), + '19-05-1994', + $properties, + $context, + ); + } + + public function testFailsWithOtherTypes(): void + { + [$properties, $context] = $this->operation([DATE_ATOM]); + + $this->assertSame( + Uncastable::create(), + (new DateTimeInterfaceCast('d-m-Y'))->cast($this->property('int'), '1994-05-16 12:20:00', $properties, $context), + ); + } + + public function testCanSetAnAlternativeTimezone(): void + { + [$properties, $context] = $this->operation([DATE_ATOM]); + $cast = new DateTimeInterfaceCast('d-m-Y H:i:s', setTimeZone: 'Europe/Brussels'); + + foreach (['carbon', 'carbonImmutable'] as $property) { + $date = $cast->cast($this->property($property), '19-05-1994 00:00:00', $properties, $context); + + $this->assertEquals('1994-05-19 02:00:00', $date->format('Y-m-d H:i:s')); + $this->assertEquals(CarbonTimeZone::create('Europe/Brussels'), $date->getTimezone()); + } + + foreach (['mutable', 'immutable'] as $property) { + $date = $cast->cast($this->property($property), '19-05-1994 00:00:00', $properties, $context); + + $this->assertEquals('1994-05-19 02:00:00', $date->format('Y-m-d H:i:s')); + $this->assertEquals(new DateTimeZone('Europe/Brussels'), $date->getTimezone()); + } + } + + public function testCanCastDateTimesWithATimezone(): void + { + [$properties, $context] = $this->operation([DATE_ATOM]); + $cast = new DateTimeInterfaceCast('d-m-Y H:i:s', timeZone: 'Europe/Brussels'); + + foreach (['carbon', 'carbonImmutable'] as $property) { + $date = $cast->cast($this->property($property), '19-05-1994 00:00:00', $properties, $context); + + $this->assertEquals('1994-05-19 00:00:00', $date->format('Y-m-d H:i:s')); + $this->assertEquals(CarbonTimeZone::create('Europe/Brussels'), $date->getTimezone()); + } + + foreach (['mutable', 'immutable'] as $property) { + $date = $cast->cast($this->property($property), '19-05-1994 00:00:00', $properties, $context); + + $this->assertEquals('1994-05-19 00:00:00', $date->format('Y-m-d H:i:s')); + $this->assertEquals(new DateTimeZone('Europe/Brussels'), $date->getTimezone()); + } + } + + public function testCanDefineMultipleDateFormatsToBeUsed(): void + { + $data = new class extends Data { + /** + * Create the data object with several accepted date formats. + */ + public function __construct( + #[WithCast(DateTimeInterfaceCast::class, ['Y-m-d\TH:i:sP', 'Y-m-d H:i:s'])] + public ?DateTime $date = null + ) { + } + }; + + $this->assertSame(['date' => '2022-05-16T14:37:56+00:00'], $data::from(['date' => '2022-05-16T14:37:56+00:00'])->toArray()); + $this->assertSame(['date' => '2022-05-16T17:00:00+00:00'], $data::from(['date' => '2022-05-16 17:00:00'])->toArray()); + } + + public function testCanCastDateTimesWithNanosecondPrecisionByTruncatingNanosecondsToMicroseconds(): void + { + [$properties, $context] = $this->operation([DATE_ATOM]); + $cast = new DateTimeInterfaceCast('Y-m-d\TH:i:s.u\Z'); + $date = '2024-12-02T16:20:15.969827247Z'; + + $this->assertEquals(new Carbon($date), $cast->cast($this->property('carbon'), $date, $properties, $context)); + $this->assertEquals(new CarbonImmutable($date), $cast->cast($this->property('carbonImmutable'), $date, $properties, $context)); + $this->assertEquals(new DateTime($date), $cast->cast($this->property('mutable'), $date, $properties, $context)); + $this->assertEquals(new DateTimeImmutable($date), $cast->cast($this->property('immutable'), $date, $properties, $context)); + } + + #[DataProvider('nanosecondOffsetDates')] + public function testCanCastDateTimesWithNanosecondPrecisionAndATimezoneOffsetByTruncatingNanosecondsToMicroseconds(string $date, string $format): void + { + [$properties, $context] = $this->operation([DATE_ATOM]); + $cast = new DateTimeInterfaceCast($format); + + $this->assertEquals(new Carbon($date), $cast->cast($this->property('carbon'), $date, $properties, $context)); + $this->assertEquals(new CarbonImmutable($date), $cast->cast($this->property('carbonImmutable'), $date, $properties, $context)); + $this->assertEquals(new DateTime($date), $cast->cast($this->property('mutable'), $date, $properties, $context)); + $this->assertEquals(new DateTimeImmutable($date), $cast->cast($this->property('immutable'), $date, $properties, $context)); + } + + /** + * Provide nanosecond-precision dates with their formats. + */ + public static function nanosecondOffsetDates(): array + { + return [ + 'positive offset, 7 digits' => ['2026-03-10T09:55:56.9918655+01:00', 'Y-m-d\TH:i:s.uP'], + 'negative offset, 7 digits' => ['2026-03-10T09:55:56.9918655-05:00', 'Y-m-d\TH:i:s.uP'], + 'zero offset, 9 digits' => ['2026-03-10T09:55:56.969827247+00:00', 'Y-m-d\TH:i:s.uP'], + 'Z suffix, 7 digits' => ['2026-03-10T09:55:56.9918655Z', 'Y-m-d\TH:i:s.u\Z'], + ]; + } + /** * Test context formats and exact concrete date targets are preserved. */ public function testCastsConfiguredFormatsToExactConcreteTypes(): void { - [$state, $context] = $this->operation(['Y-m-d', 'Y-m-d H:i:s.uP']); + [$properties, $context] = $this->operation(['Y-m-d', 'Y-m-d H:i:s.uP']); $cast = new DateTimeInterfaceCast; $types = [ @@ -51,7 +206,7 @@ public function testCastsConfiguredFormatsToExactConcreteTypes(): void ]; foreach ($types as $property => $type) { - $date = $cast->cast($this->property($property), '2026-08-30', $state, $context); + $date = $cast->cast($this->property($property), '2026-08-30', $properties, $context); $this->assertSame($type, $date::class); $this->assertSame('2026-08-30', $date->format('Y-m-d')); @@ -63,12 +218,12 @@ public function testCastsConfiguredFormatsToExactConcreteTypes(): void */ public function testCastsDateInterfacesThroughTheDateFactory(): void { - [$state, $context] = $this->operation(['Y-m-d']); + [$properties, $context] = $this->operation(['Y-m-d']); $date = (new DateTimeInterfaceCast)->cast( $this->property('interface'), '2026-08-30', - $state, + $properties, $context, ); @@ -81,7 +236,7 @@ public function testCastsDateInterfacesThroughTheDateFactory(): void */ public function testAppliesTimezonesAndTruncatesNanoseconds(): void { - [$state, $context] = $this->operation(['Y-m-d H:i:s.uP']); + [$properties, $context] = $this->operation(['Y-m-d H:i:s.uP']); $cast = new DateTimeInterfaceCast( format: 'Y-m-d H:i:s.uP', type: DateTimeImmutable::class, @@ -92,7 +247,7 @@ public function testAppliesTimezonesAndTruncatesNanoseconds(): void $date = $cast->cast( $this->property('immutable'), '2026-08-30 12:00:00.123456789+00:00', - $state, + $properties, $context, ); @@ -104,7 +259,7 @@ public function testAppliesTimezonesAndTruncatesNanoseconds(): void */ public function testTimezoneConversionPreservesExactConcreteTypes(): void { - [$state, $context] = $this->operation(['Y-m-d H:i:s']); + [$properties, $context] = $this->operation(['Y-m-d H:i:s']); $types = [ 'mutable' => DateTime::class, 'immutable' => DateTimeImmutable::class, @@ -121,47 +276,21 @@ public function testTimezoneConversionPreservesExactConcreteTypes(): void format: 'Y-m-d H:i:s', setTimeZone: 'America/New_York', timeZone: 'UTC', - ))->cast($this->property($property), '2026-08-30 12:00:00', $state, $context); + ))->cast($this->property($property), '2026-08-30 12:00:00', $properties, $context); $this->assertSame($type, $date::class); $this->assertSame('2026-08-30 08:00:00-04:00', $date->format('Y-m-d H:i:sP')); } } - /** - * Test iterable date declarations and non-date declarations. - */ - public function testCastsIterableDatesAndDeclinesNonDateProperties(): void + public function testCastsIterableDates(): void { - [$state, $context] = $this->operation(['Y-m-d']); - $cast = new DateTimeInterfaceCast; + [$properties, $context] = $this->operation(['Y-m-d']); - $date = $cast->castIterableItem($this->property('dates'), '2026-08-30', $state, $context); + $date = (new DateTimeInterfaceCast)->castIterableItem($this->property('dates'), '2026-08-30', $properties, $context); $this->assertInstanceOf(DateTimeImmutable::class, $date); - $this->assertSame( - Uncastable::create(), - $cast->cast($this->property('name'), '2026-08-30', $state, $context), - ); - } - - /** - * Test invalid dates name the target and accepted formats. - */ - public function testThrowsWhenNoDateFormatMatches(): void - { - [$state, $context] = $this->operation(['Y-m-d']); - - $this->expectException(CannotCastDate::class); - $this->expectExceptionMessageIsOrContains(DateTimeImmutable::class); - $this->expectExceptionMessageMatches('/Y-m-d/'); - - (new DateTimeInterfaceCast)->cast( - $this->property('immutable'), - 'not-a-date', - $state, - $context, - ); + $this->assertSame('2026-08-30', $date->format('Y-m-d')); } /** @@ -169,7 +298,7 @@ public function testThrowsWhenNoDateFormatMatches(): void */ public function testThrowsForAbstractDateTarget(): void { - [$state, $context] = $this->operation(['Y-m-d']); + [$properties, $context] = $this->operation(['Y-m-d']); $this->expectException(CannotCastDate::class); $this->expectExceptionMessageIsOrContains(AbstractDateTimeImmutable::class); @@ -177,7 +306,7 @@ public function testThrowsForAbstractDateTarget(): void (new DateTimeInterfaceCast)->cast( $this->property('abstract'), '2026-08-30', - $state, + $properties, $context, ); } @@ -189,7 +318,7 @@ protected function property(string $name): DataProperty { $defaults = require __DIR__ . '/../../../src/data/config/data.php'; $config = new DataConfig(new Repository(['data' => $defaults])); - $typeFactory = new DataTypeFactory(new PhpDocTypeNameResolver); + $typeFactory = new DataTypeFactory(new PhpDocTypeNameResolver, new DataIterableAnnotationReader); $reflectionClass = new ReflectionClass(DateCastDataFixture::class); $reflectionProperty = $reflectionClass->getProperty($name); @@ -210,16 +339,14 @@ classDefinedDataIterableAnnotations: (new DataIterableAnnotationReader)->getForP * Create one date cast operation. * * @param non-empty-list $formats - * @return array{ConstructionState, CreationContext} + * @return array{array, CreationContext} */ protected function operation(array $formats): array { - $context = new CreationContext( + return [[], new CreationContext( dataClass: DateCastDataContract::class, dateFormats: $formats, - ); - - return [ConstructionState::create($context, DateCastDataContract::class), $context]; + )]; } } @@ -248,7 +375,7 @@ class DateCastDataFixture /** @var list */ public array $dates; - public string $name; + public int $int; } class CustomDateTimeImmutable extends DateTimeImmutable diff --git a/tests/Data/Casts/EnumCastTest.php b/tests/Data/Casts/EnumCastTest.php index 4e32c0e217..cc902d8310 100644 --- a/tests/Data/Casts/EnumCastTest.php +++ b/tests/Data/Casts/EnumCastTest.php @@ -11,7 +11,6 @@ use Hypervel\Data\Contracts\BaseData; use Hypervel\Data\Exceptions\CannotCastEnum; use Hypervel\Data\Support\Annotations\DataIterableAnnotationReader; -use Hypervel\Data\Support\Creation\ConstructionState; use Hypervel\Data\Support\Creation\CreationContext; use Hypervel\Data\Support\DataConfig; use Hypervel\Data\Support\DataProperty; @@ -24,18 +23,45 @@ class EnumCastTest extends TestCase { - /** - * Test declared backed enum values are cast or passed through. - */ - public function testCastsDeclaredBackedEnumValues(): void + public function testCanCastEnum(): void { - [$state, $context] = $this->operation(); + [$properties, $context] = $this->operation(); $property = $this->property('status'); $cast = new EnumCast; - $this->assertSame(EnumCastStatus::Ready, $cast->cast($property, 'ready', $state, $context)); - $this->assertSame(EnumCastStatus::Ready, $cast->cast($property, EnumCastStatus::Ready, $state, $context)); - $this->assertSame(EnumCastStatus::Ready, $cast->cast($property, OtherEnumCastStatus::Ready, $state, $context)); + $this->assertSame(EnumCastStatus::Ready, $cast->cast($property, 'ready', $properties, $context)); + $this->assertSame(EnumCastStatus::Ready, $cast->cast($property, EnumCastStatus::Ready, $properties, $context)); + $this->assertSame(EnumCastStatus::Ready, $cast->cast($property, OtherEnumCastStatus::Ready, $properties, $context)); + } + + public function testFailsWhenItCannotCastAnEnumFromValue(): void + { + [$properties, $context] = $this->operation(); + + $this->expectException(CannotCastEnum::class); + $this->expectExceptionMessageIsOrContains('EnumCastDataFixture::$status'); + + (new EnumCast)->cast($this->property('status'), 'invalid', $properties, $context); + } + + public function testFailsWhenCastingAnUnitEnum(): void + { + [$properties, $context] = $this->operation(); + + $this->assertSame( + Uncastable::create(), + (new EnumCast)->cast($this->property('unit'), 'Ready', $properties, $context), + ); + } + + public function testFailsWithOtherTypes(): void + { + [$properties, $context] = $this->operation(); + + $this->assertSame( + Uncastable::create(), + (new EnumCast)->cast($this->property('int'), 'ready', $properties, $context), + ); } /** @@ -43,11 +69,11 @@ public function testCastsDeclaredBackedEnumValues(): void */ public function testCastsIntegerBackedEnumFromNumericString(): void { - [$state, $context] = $this->operation(); + [$properties, $context] = $this->operation(); $this->assertSame( IntegerEnumCastStatus::Ready, - (new EnumCast)->cast($this->property('integerStatus'), '1', $state, $context), + (new EnumCast)->cast($this->property('integerStatus'), '1', $properties, $context), ); } @@ -56,45 +82,19 @@ public function testCastsIntegerBackedEnumFromNumericString(): void */ public function testCastsIterableBackedEnumValues(): void { - [$state, $context] = $this->operation(); + [$properties, $context] = $this->operation(); $this->assertSame( EnumCastStatus::Done, (new EnumCast)->castIterableItem( $this->property('statuses'), 'done', - $state, + $properties, $context, ), ); } - /** - * Test a declaration without a backed enum declines the cast. - */ - public function testReturnsUncastableWithoutABackedEnumDeclaration(): void - { - [$state, $context] = $this->operation(); - - $this->assertSame( - Uncastable::create(), - (new EnumCast)->cast($this->property('name'), 'ready', $state, $context), - ); - } - - /** - * Test invalid enum values produce a property-specific exception. - */ - public function testThrowsForAnInvalidBackedEnumValue(): void - { - [$state, $context] = $this->operation(); - - $this->expectException(CannotCastEnum::class); - $this->expectExceptionMessageIsOrContains('EnumCastDataFixture::$status'); - - (new EnumCast)->cast($this->property('status'), 'invalid', $state, $context); - } - /** * Build one property definition. */ @@ -102,7 +102,7 @@ protected function property(string $name): DataProperty { $defaults = require __DIR__ . '/../../../src/data/config/data.php'; $config = new DataConfig(new Repository(['data' => $defaults])); - $typeFactory = new DataTypeFactory(new PhpDocTypeNameResolver); + $typeFactory = new DataTypeFactory(new PhpDocTypeNameResolver, new DataIterableAnnotationReader); $reflectionClass = new ReflectionClass(EnumCastDataFixture::class); return (new DataPropertyFactory( @@ -121,13 +121,11 @@ classDefinedDataIterableAnnotations: (new DataIterableAnnotationReader)->getForP /** * Create one cast operation. * - * @return array{ConstructionState, CreationContext} + * @return array{array, CreationContext} */ protected function operation(): array { - $context = new CreationContext(EnumCastDataContract::class); - - return [ConstructionState::create($context, EnumCastDataContract::class), $context]; + return [[], new CreationContext(EnumCastDataContract::class)]; } } @@ -147,16 +145,23 @@ enum IntegerEnumCastStatus: int case Ready = 1; } +enum UnitEnumCastStatus +{ + case Ready; +} + class EnumCastDataFixture { public EnumCastStatus $status; public IntegerEnumCastStatus $integerStatus; + public UnitEnumCastStatus $unit; + /** @var list */ public array $statuses; - public string $name; + public int $int; } abstract class EnumCastDataContract implements BaseData diff --git a/tests/Data/CollectionAttributeWithAnotationsTest.php b/tests/Data/CollectionAttributeWithAnotationsTest.php new file mode 100644 index 0000000000..197c0c0494 --- /dev/null +++ b/tests/Data/CollectionAttributeWithAnotationsTest.php @@ -0,0 +1,65 @@ + [ + ['string' => 'string1'], + ['string' => 'string2'], + ['string' => 'string3'], + ], + ]; + + /** + * Get package providers for the test application. + */ + protected function getPackageProviders(Application $app): array + { + return [DataServiceProvider::class]; + } + + public function testCanCreateADataObjectWithACollectionAttributeFromArrayAndBack(): void + { + $data = DataWithSimpleDataCollectionWithAnotations::from($this->payload); + + $this->assertEquals(new DataWithSimpleDataCollectionWithAnotations( + collection: new SimpleDataCollectionWithAnotations([ + new SimpleData(string: 'string1'), + new SimpleData(string: 'string2'), + new SimpleData(string: 'string3'), + ]) + ), $data); + + $this->assertSame($this->payload, $data->toArray()); + } + + public function testCanValidateADataObjectWithACollectionAttribute(): void + { + DataValidationAsserter::for(DataWithSimpleDataCollectionWithAnotations::class) + ->assertOk($this->payload) + ->assertErrors(['collection' => [ + ['notExistingAttribute' => 'xxx'], + ]]) + ->assertRules( + rules: [ + 'collection' => ['present', 'array'], + 'collection.0.string' => ['required', 'string'], + 'collection.1.string' => ['required', 'string'], + 'collection.2.string' => ['required', 'string'], + ], + payload: $this->payload + ); + } +} diff --git a/tests/Data/Concerns/AppendableDataTest.php b/tests/Data/Concerns/AppendableDataTest.php index ce2cb3be63..1e5ce39939 100644 --- a/tests/Data/Concerns/AppendableDataTest.php +++ b/tests/Data/Concerns/AppendableDataTest.php @@ -5,7 +5,6 @@ namespace Hypervel\Tests\Data\Concerns; use Hypervel\Contracts\Foundation\Application; -use Hypervel\Data\Data; use Hypervel\Data\DataServiceProvider; use Hypervel\Data\Resource; use Hypervel\Testbench\TestCase; @@ -21,98 +20,7 @@ protected function getPackageProviders(Application $app): array } /** - * Test additional data may be declared by the data class. - */ - public function testAppendsDataFromWithMethod(): void - { - $data = new class('Taylor') extends Data { - public function __construct(public string $name) - { - } - - public function with(): array - { - return ['label' => "{$this->name} from Hypervel"]; - } - }; - - $this->assertSame([ - 'name' => 'Taylor', - 'label' => 'Taylor from Hypervel', - ], $data->toArray()); - } - - /** - * Test additional method closures receive the current data object. - */ - public function testResolvesWithMethodClosures(): void - { - $data = new class('Taylor') extends Data { - public function __construct(public string $name) - { - } - - public function with(): array - { - return [ - 'label' => static fn (self $data): string => "{$data->name} from Hypervel", - ]; - } - }; - - $this->assertSame([ - 'name' => 'Taylor', - 'label' => 'Taylor from Hypervel', - ], $data->toArray()); - } - - /** - * Test additional data may be supplied fluently. - */ - public function testAppendsDataFromAdditionalMethod(): void - { - $data = new class('Taylor') extends Data { - public function __construct(public string $name) - { - } - }; - - $transformed = $data->additional([ - 'company' => 'Hypervel', - 'label' => static fn (Data $data): string => "{$data->name} from Hypervel", - ])->toArray(); - - $this->assertSame([ - 'name' => 'Taylor', - 'company' => 'Hypervel', - 'label' => 'Taylor from Hypervel', - ], $transformed); - } - - /** - * Test fluent additional data takes precedence over class data. - */ - public function testAdditionalMethodTakesPrecedenceOverWithMethod(): void - { - $data = new class('Taylor') extends Data { - public function __construct(public string $name) - { - } - - public function with(): array - { - return ['label' => 'class']; - } - }; - - $this->assertSame([ - 'name' => 'Taylor', - 'label' => 'instance', - ], $data->additional(['label' => 'instance'])->toArray()); - } - - /** - * Test resources expose the same append behavior. + * Test resources expose the same append behavior as data objects (AppendTest). */ public function testResourceAppendsAdditionalData(): void { diff --git a/tests/Data/Concerns/EmptyDataTest.php b/tests/Data/Concerns/EmptyDataTest.php index f5e95db348..a50d1aaf96 100644 --- a/tests/Data/Concerns/EmptyDataTest.php +++ b/tests/Data/Concerns/EmptyDataTest.php @@ -5,13 +5,9 @@ namespace Hypervel\Tests\Data\Concerns\EmptyDataTest; use Hypervel\Contracts\Foundation\Application; -use Hypervel\Data\Attributes\MapOutputName; use Hypervel\Data\Data; use Hypervel\Data\DataServiceProvider; -use Hypervel\Data\Exceptions\DataPropertyCanOnlyHaveOneType; -use Hypervel\Data\Lazy; use Hypervel\Data\Resource; -use Hypervel\Support\Collection; use Hypervel\Testbench\TestCase; class EmptyDataTest extends TestCase @@ -25,26 +21,9 @@ protected function getPackageProviders(Application $app): array } /** - * Test empty values derive from declared property types. + * Test a custom empty value replaces null without replacing supplied values. */ - public function testCreatesEmptyRepresentation(): void - { - $this->assertSame([ - 'property' => null, - 'lazyProperty' => null, - 'array' => [], - 'collection' => [], - 'data' => ['value' => null], - 'lazyData' => ['value' => null], - 'mapped_value' => null, - 'defaultProperty' => true, - ], EmptyShapeData::empty()); - } - - /** - * Test explicit values and a custom empty scalar are supported. - */ - public function testOverridesEmptyValues(): void + public function testReplacesNullValues(): void { $this->assertSame([ 'value' => 'supplied', @@ -55,35 +34,6 @@ public function testOverridesEmptyValues(): void ], SimpleEmptyData::empty(replaceNullValuesWith: '?')); } - /** - * Test only and except filter the output shape. - */ - public function testFiltersEmptyRepresentation(): void - { - $this->assertSame([ - 'second' => null, - ], FilteredEmptyData::empty(except: ['first'], only: ['first', 'second'])); - } - - /** - * Test ambiguous property types require an explicit value. - */ - public function testRejectsAmbiguousPropertyTypeWithoutOverride(): void - { - $this->expectException(DataPropertyCanOnlyHaveOneType::class); - $this->expectExceptionMessageIsOrContains(AmbiguousEmptyData::class . '::$value'); - - AmbiguousEmptyData::empty(); - } - - /** - * Test explicit values resolve ambiguous property types. - */ - public function testAcceptsOverrideForAmbiguousPropertyType(): void - { - $this->assertSame(['value' => 1], AmbiguousEmptyData::empty(['value' => 1])); - } - /** * Test constructor object defaults are fresh for each empty call. */ @@ -106,43 +56,11 @@ public function testResourceCreatesEmptyRepresentation(): void } } -class EmptyShapeData extends Data -{ - public string $property; - - public string|Lazy $lazyProperty; - - public array $array; - - public Collection $collection; - - public SimpleEmptyData $data; - - public Lazy|SimpleEmptyData $lazyData; - - #[MapOutputName('mapped_value')] - public string $mappedValue; - - public bool $defaultProperty = true; -} - class SimpleEmptyData extends Data { public string $value; } -class FilteredEmptyData extends Data -{ - public string $first; - - public string $second; -} - -class AmbiguousEmptyData extends Data -{ - public int|string $value; -} - class DefaultObjectData extends Data { public function __construct(public EmptyDefaultObject $value = new EmptyDefaultObject) diff --git a/tests/Data/Concerns/WrappableDataTest.php b/tests/Data/Concerns/WrappableDataTest.php index cddc5e9813..10629bec21 100644 --- a/tests/Data/Concerns/WrappableDataTest.php +++ b/tests/Data/Concerns/WrappableDataTest.php @@ -5,9 +5,12 @@ namespace Hypervel\Tests\Data\Concerns\WrappableDataTest; use Hypervel\Contracts\Foundation\Application; +use Hypervel\Data\Attributes\DataCollectionOf; use Hypervel\Data\Data; +use Hypervel\Data\DataCollection; use Hypervel\Data\DataServiceProvider; use Hypervel\Data\Support\Transformation\TransformationContextFactory; +use Hypervel\Http\Request; use Hypervel\Testbench\TestCase; abstract class WrappingTestCase extends TestCase @@ -24,13 +27,27 @@ protected function getPackageProviders(Application $app): array class WrappableDataTest extends WrappingTestCase { /** - * Test default transformations remain unwrapped. + * Test a wrapped data collection on the returned object keeps its own wrapper, unlike one inside a collection + * item or toArray() output. */ - public function testDefaultTransformationsRemainUnwrapped(): void + public function testWrapsNestedDataCollectionsInResponses(): void { - $data = (new WrappingData('value'))->wrap('payload'); + $songs = (new DataCollection(WrappingData::class, [new WrappingData('song')]))->wrap('data'); + $album = (new WrappingAlbumData('Album', $songs))->wrap('data'); - $this->assertSame(['value' => 'value'], $data->toArray()); + $this->assertSame([ + 'data' => [ + 'title' => 'Album', + 'songs' => ['data' => [['value' => 'song']]], + ], + ], $album->toResponse(Request::create('/'))->getData(true)); + $this->assertSame([ + ['title' => 'Album', 'songs' => [['value' => 'song']]], + ], (new DataCollection(WrappingAlbumData::class, [$album]))->toResponse(Request::create('/'))->getData(true)); + $this->assertSame([ + 'title' => 'Album', + 'songs' => [['value' => 'song']], + ], $album->toArray()); } /** @@ -90,3 +107,16 @@ public function __construct(public WrappingData $nested) { } } + +class WrappingAlbumData extends Data +{ + /** + * Create an album with a nested song collection. + */ + public function __construct( + public string $title, + #[DataCollectionOf(WrappingData::class)] + public DataCollection $songs, + ) { + } +} diff --git a/tests/Data/Console/DataMakeCommandTest.php b/tests/Data/Console/DataMakeCommandTest.php index 2ebb0f8aa2..0f46a3008f 100644 --- a/tests/Data/Console/DataMakeCommandTest.php +++ b/tests/Data/Console/DataMakeCommandTest.php @@ -7,6 +7,7 @@ use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Data\DataServiceProvider; use Hypervel\Filesystem\Filesystem; +use Hypervel\Testbench\Attributes\DefineEnvironment; use Hypervel\Testbench\TestCase; use Symfony\Component\Process\Process; @@ -35,20 +36,69 @@ protected function tearDown(): void parent::tearDown(); } - public function testDataIsGeneratedWithoutAnImplicitSuffix(): void + public function testDataIsGeneratedWithTheConfiguredSuffix(): void { $this->artisan('make:data', [ 'name' => 'User', '--no-interaction' => true, ])->assertSuccessful(); + $this->artisan('make:data', [ + 'name' => 'TeamData', + '--no-interaction' => true, + ])->assertSuccessful(); - $path = app_path('Data/User.php'); - $contents = $this->generatedFile($path); + $contents = $this->generatedFile(app_path('Data/UserData.php')); + $suffixed = $this->generatedFile(app_path('Data/TeamData.php')); $this->assertStringContainsString('namespace App\Data;', $contents); $this->assertStringContainsString('use Hypervel\Data\Data;', $contents); - $this->assertStringContainsString('class User extends Data', $contents); + $this->assertStringContainsString('class UserData extends Data', $contents); $this->assertStringContainsString('declare(strict_types=1);', $contents); + $this->assertStringContainsString('class TeamData extends Data', $suffixed); + } + + public function testNamespaceAndSuffixOptionsOverrideTheConfiguredDefaults(): void + { + $this->artisan('make:data', [ + 'name' => 'Invoice', + '--target-namespace' => 'App\Transfer', + '--suffix' => 'Dto', + '--no-interaction' => true, + ])->assertSuccessful(); + $this->artisan('make:data', [ + 'name' => 'Receipt', + '--suffix' => '', + '--no-interaction' => true, + ])->assertSuccessful(); + + $custom = $this->generatedFile(app_path('Transfer/InvoiceDto.php')); + $unsuffixed = $this->generatedFile(app_path('Data/Receipt.php')); + + $this->assertStringContainsString('namespace App\Transfer;', $custom); + $this->assertStringContainsString('class InvoiceDto extends Data', $custom); + $this->assertStringContainsString('class Receipt extends Data', $unsuffixed); + } + + #[DefineEnvironment('withoutConfiguredSuffix')] + public function testConfiguredDefaultsApplyWhenNoOptionIsGiven(): void + { + $this->artisan('make:data', [ + 'name' => 'Profile', + '--no-interaction' => true, + ])->assertSuccessful(); + + $contents = $this->generatedFile(app_path('Dto/Profile.php')); + + $this->assertStringContainsString('namespace App\Dto;', $contents); + $this->assertStringContainsString('class Profile extends Data', $contents); + } + + /** + * Configure an unsuffixed generator namespace. + */ + protected function withoutConfiguredSuffix(ApplicationContract $app): void + { + $app->make('config')->set('data.commands.make', ['namespace' => 'Dto', 'suffix' => '']); } public function testNestedAndQualifiedNamesUseTheirDeclaredNamespaces(): void diff --git a/tests/Data/CreationFactoryTest.php b/tests/Data/CreationFactoryTest.php new file mode 100644 index 0000000000..975115db6f --- /dev/null +++ b/tests/Data/CreationFactoryTest.php @@ -0,0 +1,255 @@ +assertEquals( + new $data(null, 'Taylor'), + $data::factory()->withoutMagicalCreation()->from(['hash_id' => '1', 'name' => 'Taylor']), + ); + $this->assertEquals(new $data('1', 'Taylor'), $data::from(['hash_id' => '1', 'name' => 'Taylor'])); + } + + public function testCanCreateDataIgnoringCertainMagicalMethods(): void + { + $data = new class('', '') extends Data { + /** + * Create the data object from an identifier and a name. + */ + public function __construct( + public ?string $id, + public string $name, + ) { + } + + /** + * Create the data object from a payload with a hashed identifier. + */ + public static function fromArray(array $payload): self + { + return new self( + id: $payload['hash_id'] ?? null, + name: $payload['name'], + ); + } + }; + + $this->assertEquals( + new $data(null, 'Taylor'), + $data::factory()->ignoreMagicalMethod('fromArray')->from(['hash_id' => '1', 'name' => 'Taylor']), + ); + $this->assertEquals( + new $data('1', 'Taylor'), + $data::factory()->from(['hash_id' => '1', 'name' => 'Taylor']), + ); + } + + public function testCanEnableTheValidationOfNonRequestPayloads(): void + { + $dataClass = new class extends Data { + #[In('Hello World')] + public string $string; + }; + + $payload = [ + 'string' => 'nowp', + ]; + + $data = $dataClass::factory()->from($payload); + + $this->assertInstanceOf(Data::class, $data); + $this->assertSame('nowp', $data->string); + + $this->expectException(ValidationException::class); + + $dataClass::factory()->alwaysValidate()->from($payload); + } + + public function testCanDisableTheValidationRequestPayloads(): void + { + $dataClass = new class extends Data { + #[In('Hello World')] + public string $string; + }; + + $request = Request::create('/', 'POST', [ + 'string' => 'nowp', + ]); + + try { + $dataClass::factory()->from($request); + $this->fail('Expected the request payload to be validated.'); + } catch (ValidationException) { + } + + $data = $dataClass::factory()->withoutValidation()->from($request); + + $this->assertInstanceOf(Data::class, $data); + $this->assertSame('nowp', $data->string); + } + + public function testCanDisablePropertyMapping(): void + { + $dataClass = new class extends Data { + #[MapInputName('firstName')] + public string $first_name; + }; + + // Upstream leaves the unmapped property uninitialized; Hypervel rejects a missing required property. + $this->expectException(CannotCreateData::class); + $this->expectExceptionMessageIsOrContains('::$first_name] is missing'); + + $dataClass::factory()->withoutPropertyNameMapping()->from(['firstName' => 'Taylor']); + } + + public function testCanAddANewGlobalCast(): void + { + $data = SimpleData::factory()->withCast('string', new StringToUpperCast)->from([ + 'string' => 'Hello World', + ]); + + $this->assertSame('HELLO WORLD', $data->string); + } + + public function testCanAddACollectionOfGlobalCasts(): void + { + $dataClass = new class extends Data { + public string $string; + + public int $int; + }; + + $data = $dataClass::factory()->withCastCollection([ + 'string' => new StringToUpperCast, + 'int' => new MeaningOfLifeCast, + ])->from([ + 'string' => 'Hello World', + 'int' => '123', + ]); + + $this->assertSame('HELLO WORLD', $data->string); + $this->assertSame(42, $data->int); + } + + public function testCanCollectUsingAFactory(): void + { + $collection = SimpleData::factory()->withCast('string', new StringToUpperCast)->collect([ + ['string' => 'Hello World'], + ['string' => 'Hello You'], + ], Collection::class); + + $this->assertCount(2, $collection); + $this->assertSame('HELLO WORLD', $collection->first()->string); + $this->assertSame('HELLO YOU', $collection->last()->string); + } + + public function testIsPossibleToPassAnotherCreationContextToAFactoryAsBase(): void + { + $baseCreationContext = SimpleData::factory() + ->alwaysValidate() + ->get(); + + $creationContext = SimpleData::factory($baseCreationContext)->get(); + + $this->assertSame(ValidationStrategy::Always, $creationContext->validationStrategy); + } + + public function testABaseCreationContextSuppliesItsOptionsButNotItsHooks(): void + { + $normalizer = new class implements Normalizer { + /** + * Leave every value for the next normalizer. + */ + public function normalize(mixed $value): ?array + { + return null; + } + }; + + $base = SimpleData::factory() + ->withoutValidation() + ->withoutPropertyNameMapping() + ->withoutMagicalCreation() + ->withoutOptionalValues() + ->ignoreMagicalMethod('fromString') + ->withCast('string', StringToUpperCast::class) + ->withNormalizers($normalizer) + ->prepareData(static fn (array $payload): array => $payload) + ->get(); + + $factory = SimpleDto::factory($base); + $context = $factory->get(); + + $this->assertSame(SimpleDto::class, $context->dataClass); + $this->assertSame(ValidationStrategy::Disabled, $context->validationStrategy); + $this->assertFalse($context->mapPropertyNames); + $this->assertTrue($context->disableMagicalCreation); + $this->assertFalse($context->useOptionalValues); + $this->assertSame(['fromString'], $context->ignoredMagicalMethods); + $this->assertSame(['string' => StringToUpperCast::class], $context->casts); + $this->assertSame([$normalizer], $context->normalizers); + $this->assertSame([], $context->prepareDataHooks); + + $customized = $factory->withCast('int', MeaningOfLifeCast::class)->get(); + + $this->assertSame(ValidationStrategy::Disabled, $customized->validationStrategy); + $this->assertSame(['string' => StringToUpperCast::class, 'int' => MeaningOfLifeCast::class], $customized->casts); + + $default = SimpleDto::factory()->get(); + + $this->assertSame(ValidationStrategy::OnlyRequests, $default->validationStrategy); + $this->assertSame([], $default->casts); + } +} diff --git a/tests/Data/CreationTest.php b/tests/Data/CreationTest.php new file mode 100644 index 0000000000..c4b3333120 --- /dev/null +++ b/tests/Data/CreationTest.php @@ -0,0 +1,2255 @@ + $this->getRefreshConnection(), + '--realpath' => true, + '--path' => __DIR__ . '/Fixtures/Migrations', + ]; + } + + public function testCanUseDefaultTypesToCreateDataObjects(): void + { + // The 'd-m-Y' cast fills the time from the clock, so the cast and the expected date must read the same time. + $this->freezeTime(); + + $data = ComplicatedData::from([ + 'withoutType' => 42, + 'int' => 42, + 'bool' => true, + 'float' => 3.14, + 'string' => 'Hello world', + 'array' => [1, 1, 2, 3, 5, 8], + 'nullable' => null, + 'mixed' => 42, + 'explicitCast' => '16-06-1994', + 'defaultCast' => '1994-05-16T12:00:00+01:00', + 'nestedData' => [ + 'string' => 'hello', + ], + 'nestedCollection' => [ + ['string' => 'never'], + ['string' => 'gonna'], + ['string' => 'give'], + ['string' => 'you'], + ['string' => 'up'], + ], + 'nestedArray' => [ + ['string' => 'never'], + ['string' => 'gonna'], + ['string' => 'give'], + ['string' => 'you'], + ['string' => 'up'], + ], + ]); + + $this->assertInstanceOf(ComplicatedData::class, $data); + $this->assertEquals(42, $data->withoutType); + $this->assertEquals(42, $data->int); + $this->assertTrue($data->bool); + $this->assertEquals(3.14, $data->float); + $this->assertEquals('Hello world', $data->string); + $this->assertEquals([1, 1, 2, 3, 5, 8], $data->array); + $this->assertNull($data->nullable); + $this->assertInstanceOf(Optional::class, $data->undefinable); + $this->assertEquals(42, $data->mixed); + $this->assertEquals(DateTime::createFromFormat(DATE_ATOM, '1994-05-16T12:00:00+01:00'), $data->defaultCast); + $this->assertEquals(CarbonImmutable::createFromFormat('d-m-Y', '16-06-1994'), $data->explicitCast); + $this->assertEquals(SimpleData::from('hello'), $data->nestedData); + $this->assertEquals(SimpleData::collect([ + SimpleData::from('never'), + SimpleData::from('gonna'), + SimpleData::from('give'), + SimpleData::from('you'), + SimpleData::from('up'), + ], DataCollection::class), $data->nestedCollection); + $this->assertEquals(SimpleData::collect([ + SimpleData::from('never'), + SimpleData::from('gonna'), + SimpleData::from('give'), + SimpleData::from('you'), + SimpleData::from('up'), + ]), $data->nestedArray); + } + + public function testWontCastAPropertyThatIsAlreadyInTheCorrectType(): void + { + $explicitCast = DateTime::createFromFormat('d-m-Y', '16-06-1994'); + + $data = ComplicatedData::from([ + 'withoutType' => 42, + 'int' => 42, + 'bool' => true, + 'float' => 3.14, + 'string' => 'Hello world', + 'array' => [1, 1, 2, 3, 5, 8], + 'nullable' => null, + 'mixed' => 42, + 'explicitCast' => $explicitCast, + 'defaultCast' => DateTime::createFromFormat(DATE_ATOM, '1994-05-16T12:00:00+02:00'), + 'nestedData' => SimpleData::from('hello'), + 'nestedCollection' => SimpleData::collect([ + 'never', 'gonna', 'give', 'you', 'up', + ], DataCollection::class), + 'nestedArray' => SimpleData::collect([ + 'never', 'gonna', 'give', 'you', 'up', + ]), + ]); + + $this->assertInstanceOf(ComplicatedData::class, $data); + $this->assertEquals(42, $data->withoutType); + $this->assertEquals(42, $data->int); + $this->assertTrue($data->bool); + $this->assertEquals(3.14, $data->float); + $this->assertEquals('Hello world', $data->string); + $this->assertEquals([1, 1, 2, 3, 5, 8], $data->array); + $this->assertNull($data->nullable); + $this->assertSame(42, $data->mixed); + $this->assertEquals(DateTime::createFromFormat(DATE_ATOM, '1994-05-16T12:00:00+02:00'), $data->defaultCast); + $this->assertSame($explicitCast, $data->explicitCast); + $this->assertEquals(SimpleData::from('hello'), $data->nestedData); + $this->assertEquals(SimpleData::collect([ + SimpleData::from('never'), + SimpleData::from('gonna'), + SimpleData::from('give'), + SimpleData::from('you'), + SimpleData::from('up'), + ], DataCollection::class), $data->nestedCollection); + $this->assertEquals(SimpleData::collect([ + SimpleData::from('never'), + SimpleData::from('gonna'), + SimpleData::from('give'), + SimpleData::from('you'), + SimpleData::from('up'), + ]), $data->nestedArray); + } + + public function testAllowsCreatingDataObjectsFromNull(): void + { + $dataClass = new class extends Data { + public ?string $name; + }; + + $data = $dataClass::from(null); + + $this->assertNull($data->name); + } + + public function testAllowsCreatingDataObjectsUsingLazy(): void + { + $data = NestedLazyData::from([ + 'simple' => Lazy::create(fn (): SimpleData => SimpleData::from('Hello')), + ]); + + $this->assertInstanceOf(Lazy::class, $data->simple); + $this->assertInstanceOf(SimpleData::class, $data->simple->resolve()); + $this->assertEquals('Hello', $data->simple->resolve()->string); + } + + public function testCanSetACustomCast(): void + { + $dataClass = new class extends Data { + #[WithCast(DateTimeInterfaceCast::class, format: 'Y-m-d')] + public DateTimeImmutable $date; + }; + + $data = $dataClass::from([ + 'date' => '2022-01-18', + ]); + + $this->assertInstanceOf(DateTimeImmutable::class, $data->date); + $this->assertEquals('2022-01-18', $data->date->format('Y-m-d')); + } + + public function testAllowsCastingOfEnums(): void + { + $data = EnumData::from([ + 'enum' => 'foo', + ]); + + $this->assertInstanceOf(DummyBackedEnum::class, $data->enum); + $this->assertEquals(DummyBackedEnum::FOO, $data->enum); + } + + public function testCanOptionallyCreateData(): void + { + $this->assertNull(SimpleData::optional()); + $this->assertNull(SimpleData::optional(null)); + $this->assertEquals( + new SimpleData('Hello world'), + SimpleData::optional(['string' => 'Hello world']) + ); + } + + public function testCanCreateADataModelWithoutConstructor(): void + { + $this->assertEquals( + SimpleDataWithoutConstructor::fromString('Hello'), + SimpleDataWithoutConstructor::from('Hello') + ); + + $this->assertEquals( + SimpleDataWithoutConstructor::fromString('Hello'), + SimpleDataWithoutConstructor::from([ + 'string' => 'Hello', + ]) + ); + + $this->assertEquals( + new DataCollection(SimpleDataWithoutConstructor::class, [ + SimpleDataWithoutConstructor::fromString('Hello'), + SimpleDataWithoutConstructor::fromString('World'), + ]), + SimpleDataWithoutConstructor::collect(['Hello', 'World'], DataCollection::class) + ); + } + + public function testCanCreateADataObjectFromAModel(): void + { + DummyModel::migrate(); + + $model = DummyModel::create([ + 'string' => 'test', + 'boolean' => true, + 'date' => CarbonImmutable::create(2020, 05, 16, 12, 00, 00), + 'nullable_date' => null, + 'nullable_optional_date' => null, + ]); + + $dataClass = new class extends Data { + public string $string; + + public bool $boolean; + + public Carbon $date; + + public ?Carbon $nullable_date; + + public Optional|Carbon $optional_date; + + public Optional|Carbon|null $nullable_optional_date; + }; + + // A column left out of the query is absent; a selected column holding null is a supplied null. + $data = $dataClass::from(DummyModel::query() + ->select(['id', 'string', 'boolean', 'date', 'nullable_date', 'nullable_optional_date']) + ->findOrFail($model->id)); + + $this->assertEquals('test', $data->string); + $this->assertTrue($data->boolean); + $this->assertNull($data->nullable_date); + $this->assertInstanceOf(Optional::class, $data->optional_date); + $this->assertNull($data->nullable_optional_date); + $this->assertTrue(CarbonImmutable::create(2020, 05, 16, 12, 00, 00)->eq($data->date)); + + $this->expectException(TypeError::class); + + $dataClass::from(DummyModel::findOrFail($model->id)); + } + + public function testCanCreateADataObjectFromAStdClassObject(): void + { + $object = (object) [ + 'string' => 'test', + 'boolean' => true, + 'date' => CarbonImmutable::create(2020, 05, 16, 12, 00, 00), + 'nullable_date' => null, + ]; + + $dataClass = new class extends Data { + public string $string; + + public bool $boolean; + + public CarbonImmutable $date; + + public ?Carbon $nullable_date; + }; + + $data = $dataClass::from($object); + + $this->assertEquals('test', $data->string); + $this->assertTrue($data->boolean); + $this->assertNull($data->nullable_date); + $this->assertTrue(CarbonImmutable::create(2020, 05, 16, 12, 00, 00)->eq($data->date)); + } + + public function testHasSupportForReadonlyProperties(): void + { + $dataClass = new class('') extends Data { + /** + * Create the data object with a readonly property. + */ + public function __construct( + public readonly string $string + ) { + } + }; + + $data = $dataClass::from(['string' => 'Hello world']); + + $this->assertInstanceOf($dataClass::class, $data); + $this->assertEquals('Hello world', $data->string); + } + + public function testHasSupportForIntersectionTypes(): void + { + $collection = collect(['a', 'b', 'c']); + + $dataClass = new class extends Data { + public Arrayable&Countable $intersection; + }; + + $data = $dataClass::from(['intersection' => $collection]); + + $this->assertInstanceOf($dataClass::class, $data); + $this->assertEquals($collection, $data->intersection); + } + + public function testCanConstructADataObjectWithBothConstructorPromotedAndDefaultProperties(): void + { + $dataClass = new class('') extends Data { + public string $property; + + /** + * Create the data object with a promoted property. + */ + public function __construct( + public string $promoted_property, + ) { + } + }; + + $data = $dataClass::from([ + 'property' => 'A', + 'promoted_property' => 'B', + ]); + + $this->assertEquals('A', $data->property); + $this->assertEquals('B', $data->promoted_property); + } + + public function testCanConstructADataObjectWithDefaultValues(): void + { + $dataClass = new class('', '') extends Data { + public string $property; + + public string $default_property = 'Hello'; + + /** + * Create the data object with promoted defaults. + */ + public function __construct( + public string $promoted_property, + public string $default_promoted_property = 'Hello Again', + ) { + } + }; + + $data = $dataClass::from([ + 'property' => 'Test', + 'promoted_property' => 'Test Again', + ]); + + $this->assertEquals('Test', $data->property); + $this->assertEquals('Test Again', $data->promoted_property); + $this->assertEquals('Hello', $data->default_property); + $this->assertEquals('Hello Again', $data->default_promoted_property); + } + + public function testCanConstructADataObjectWithDefaultValuesAndOverwriteThem(): void + { + $dataClass = new class('', '') extends Data { + public string $property; + + public string $default_property = 'Hello'; + + /** + * Create the data object with promoted defaults. + */ + public function __construct( + public string $promoted_property, + public string $default_promoted_property = 'Hello Again', + ) { + } + }; + + $data = $dataClass::from([ + 'property' => 'Test', + 'default_property' => 'Test', + 'promoted_property' => 'Test Again', + 'default_promoted_property' => 'Test Again', + ]); + + $this->assertEquals('Test', $data->property); + $this->assertEquals('Test Again', $data->promoted_property); + $this->assertEquals('Test', $data->default_property); + $this->assertEquals('Test Again', $data->default_promoted_property); + } + + public function testCanManuallySetValuesInTheConstructor(): void + { + $dataClass = new class('', '') extends Data { + public string $member; + + public string $other_member; + + public string $member_with_default = 'default'; + + public string $member_to_set; + + /** + * Create the data object with a promoted property. + */ + public function __construct( + public string $promoted, + string $non_promoted, + string $non_promoted_with_default = 'default', + public string $promoted_with_with_default = 'default', + ) { + $this->member = "changed_in_constructor: {$non_promoted}"; + $this->other_member = "changed_in_constructor: {$non_promoted_with_default}"; + } + }; + + $data = $dataClass::from([ + 'promoted' => 'A', + 'non_promoted' => 'B', + 'non_promoted_with_default' => 'C', + 'promoted_with_with_default' => 'D', + 'member_to_set' => 'E', + 'member_with_default' => 'F', + ]); + + $this->assertEquals([ + 'member' => 'changed_in_constructor: B', + 'other_member' => 'changed_in_constructor: C', + 'member_with_default' => 'F', + 'promoted' => 'A', + 'promoted_with_with_default' => 'D', + 'member_to_set' => 'E', + ], $data->toArray()); + + $data = $dataClass::from([ + 'promoted' => 'A', + 'non_promoted' => 'B', + 'member_to_set' => 'E', + ]); + + $this->assertEquals([ + 'member' => 'changed_in_constructor: B', + 'other_member' => 'changed_in_constructor: default', + 'member_with_default' => 'default', + 'promoted' => 'A', + 'promoted_with_with_default' => 'default', + 'member_to_set' => 'E', + ], $data->toArray()); + } + + public function testCanCastDataObjectAndCollectablesUsingACustomCast(): void + { + $dataWithDefaultCastsClass = new class extends Data { + public SimpleData $nestedData; + + #[DataCollectionOf(SimpleData::class)] + public array $nestedDataCollection; + }; + + $dataWithCustomCastsClass = new class extends Data { + #[WithCast(ConfidentialDataCast::class)] + public SimpleData $nestedData; + + #[WithCast(ConfidentialDataCollectionCast::class)] + #[DataCollectionOf(SimpleData::class)] + public array $nestedDataCollection; + }; + + $dataWithDefaultCasts = $dataWithDefaultCastsClass::from([ + 'nestedData' => 'a secret', + 'nestedDataCollection' => ['another secret', 'yet another secret'], + ]); + + $dataWithCustomCasts = $dataWithCustomCastsClass::from([ + 'nestedData' => 'a secret', + 'nestedDataCollection' => ['another secret', 'yet another secret'], + ]); + + $this->assertEquals(SimpleData::from('a secret'), $dataWithDefaultCasts->nestedData); + $this->assertEquals(SimpleData::collect(['another secret', 'yet another secret']), $dataWithDefaultCasts->nestedDataCollection); + + $this->assertEquals(SimpleData::from('CONFIDENTIAL'), $dataWithCustomCasts->nestedData); + $this->assertEquals(SimpleData::collect(['CONFIDENTIAL', 'CONFIDENTIAL']), $dataWithCustomCasts->nestedDataCollection); + } + + public function testCanCreateADataObjectWithDefaultsByCallingAnEmptyFrom(): void + { + $dataClass = new class('', '', '') extends Data { + /** + * Create the data object with nullable, optional, and default values. + */ + public function __construct( + public ?string $string, + public Optional|string $optionalString, + public string $stringWithDefault = 'Hi', + ) { + } + }; + + $this->assertEquals(new $dataClass(null, new Optional, 'Hi'), $dataClass::from([])); + } + + public function testCanCastBuiltInTypesWithCustomCasts(): void + { + $dataClass = new class('', '') extends Data { + /** + * Create the data object with and without a custom cast. + */ + public function __construct( + public string $without_cast, + #[WithCast(StringToUpperCast::class)] + public string $with_cast + ) { + } + }; + + $data = $dataClass::from([ + 'without_cast' => 'Hello World', + 'with_cast' => 'Hello World', + ]); + + $this->assertEquals('Hello World', $data->without_cast); + $this->assertEquals('HELLO WORLD', $data->with_cast); + } + + public function testCanCastDataObjectWithACastablePropertyUsingAnonymousClass(): void + { + $dataWithCastablePropertyClass = new class(new SimpleCastable('')) extends Data { + /** + * Create the data object with a castable property. + */ + public function __construct( + #[WithCastable(SimpleCastable::class)] + public SimpleCastable $castableData, + ) { + } + }; + + $dataWithCastableProperty = $dataWithCastablePropertyClass::from(['castableData' => 'HELLO WORLD']); + + $this->assertEquals(new SimpleCastable('HELLO WORLD'), $dataWithCastableProperty->castableData); + } + + public function testCanAssignAFalseValueAndTheProcessWillContinue(): void + { + $dataClass = new class extends Data { + public bool $false; + + public bool $true; + }; + + $data = $dataClass::from([ + 'false' => false, + 'true' => true, + ]); + + $this->assertFalse($data->false); + $this->assertTrue($data->true); + } + + public function testCanCreateAnPartialDataObjectUsingOptionalValues(): void + { + $dataClass = new class('', Optional::create(), Optional::create()) extends Data { + /** + * Create the data object with optional properties. + */ + public function __construct( + public string $string, + public string|Optional $undefinable_string, + #[WithCast(StringToUpperCast::class)] + public string|Optional $undefinable_string_with_cast, + ) { + } + }; + + $partialData = $dataClass::from([ + 'string' => 'Hello World', + ]); + + $this->assertEquals('Hello World', $partialData->string); + $this->assertEquals(Optional::create(), $partialData->undefinable_string); + $this->assertEquals(Optional::create(), $partialData->undefinable_string_with_cast); + + $fullData = $dataClass::from([ + 'string' => 'Hello World', + 'undefinable_string' => 'Hello World', + 'undefinable_string_with_cast' => 'Hello World', + ]); + + $this->assertEquals('Hello World', $fullData->string); + $this->assertEquals('Hello World', $fullData->undefinable_string); + $this->assertEquals('HELLO WORLD', $fullData->undefinable_string_with_cast); + } + + public function testCanUseContextInCastsBasedUponThePropertiesOfTheDataObject(): void + { + $dataClass = new class extends Data { + public SimpleData $nested; + + public string $string; + + #[WithCast(ContextAwareCast::class)] + public string $casted; + }; + + $data = $dataClass::from([ + 'nested' => 'Hello', + 'string' => 'world', + 'casted' => 'json:', + ]); + + $this->assertEquals('json:+{"nested":{"string":"Hello"},"string":"world","casted":"json:"}', $data->casted); + } + + public function testWillThrowACustomExceptionWhenADataConstructorCannotBeCalledDueToMissingComponent(): void + { + $this->expectException(CannotCreateData::class); + $this->expectExceptionMessage('its constructor requires 1 payload parameters'); + + SimpleData::from([]); + } + + public function testWillTakePropertiesFromABaseClassIntoAccountWhenCreatingADataObject(): void + { + $dataClass = new class('') extends SimpleData { + public int $int; + }; + + $data = $dataClass::from(['string' => 'Hi', 'int' => 42]); + + $this->assertSame('Hi', $data->string); + $this->assertSame(42, $data->int); + } + + public function testCanSetADefaultValueForDataObjectWhichIsTakenIntoAccountWhenCreatingTheDataObject(): void + { + $dataObject = new class('', '') extends Data { + #[Min(10)] + public string|Optional $full_name; + + /** + * Create the data object from its name parts. + */ + public function __construct( + public string $first_name, + public string $last_name, + ) { + $this->full_name = "{$this->first_name} {$this->last_name}"; + } + }; + + $data = $dataObject::from(['first_name' => 'Ruben', 'last_name' => 'Van Assche']); + + $this->assertSame('Ruben', $data->first_name); + $this->assertSame('Van Assche', $data->last_name); + $this->assertSame('Ruben Van Assche', $data->full_name); + + $data = $dataObject::from(['first_name' => 'Ruben', 'last_name' => 'Van Assche', 'full_name' => 'Ruben Versieck']); + + $this->assertSame('Ruben', $data->first_name); + $this->assertSame('Van Assche', $data->last_name); + $this->assertSame('Ruben Versieck', $data->full_name); + + $data = $dataObject::validateAndCreate(['first_name' => 'Ruben', 'last_name' => 'Van Assche']); + + $this->assertSame('Ruben', $data->first_name); + $this->assertSame('Van Assche', $data->last_name); + $this->assertSame('Ruben Van Assche', $data->full_name); + + $this->expectException(ValidationException::class); + + $dataObject::validateAndCreate(['first_name' => 'Ruben', 'last_name' => 'Van Assche', 'full_name' => 'too short']); + } + + #[DefineEnvironment('ignoreSuppliedComputedValues')] + public function testCanHaveAComputedValueWhenCreatingTheDataObject(): void + { + $data = ComputedFullNameData::from(['first_name' => 'Ruben', 'last_name' => 'Van Assche', 'full_name' => 'Something to Be Ignored']); + + $this->assertSame('Ruben', $data->first_name); + $this->assertSame('Van Assche', $data->last_name); + $this->assertSame('Ruben Van Assche', $data->full_name); + + $data = ComputedFullNameData::validateAndCreate(['first_name' => 'Ruben', 'last_name' => 'Van Assche', 'full_name' => 'Something to Be Ignored']); + + $this->assertSame('Ruben', $data->first_name); + $this->assertSame('Van Assche', $data->last_name); + $this->assertSame('Ruben Van Assche', $data->full_name); + } + + public function testCanHaveAComputedValueWhenCreatingTheDataObjectWhileRejectingSuppliedValues(): void + { + $data = ComputedFullNameData::from(['first_name' => 'Ruben', 'last_name' => 'Van Assche']); + + $this->assertSame('Ruben', $data->first_name); + $this->assertSame('Van Assche', $data->last_name); + $this->assertSame('Ruben Van Assche', $data->full_name); + + $data = ComputedFullNameData::validateAndCreate(['first_name' => 'Ruben', 'last_name' => 'Van Assche']); + + $this->assertSame('Ruben', $data->first_name); + $this->assertSame('Van Assche', $data->last_name); + $this->assertSame('Ruben Van Assche', $data->full_name); + + $this->expectException(CannotSetComputedValue::class); + + ComputedFullNameData::from(['first_name' => 'Ruben', 'last_name' => 'Van Assche', 'full_name' => 'Ruben Versieck']); + } + + #[DefineEnvironment('ignoreSuppliedComputedValues')] + public function testCanHaveAVirtualPropertyWhenCreatingTheDataObject(): void + { + $this->assertSame('virtual', SimpleDataWithPropertyHooks::from(['constructorProperty' => 'test', 'virtual' => 'Something to Be Ignored'])->virtual); + $this->assertSame('virtual', SimpleDataWithPropertyHooks::validateAndCreate(['constructorProperty' => 'test', 'virtual' => 'Something to Be Ignored'])->virtual); + } + + public function testCanHaveAVirtualPropertyWhenCreatingTheDataObjectWhileRejectingSuppliedValues(): void + { + $this->assertSame('virtual', SimpleDataWithPropertyHooks::from(['constructorProperty' => 'test'])->virtual); + $this->assertSame('virtual', SimpleDataWithPropertyHooks::validateAndCreate(['constructorProperty' => 'test'])->virtual); + + $this->expectException(CannotSetComputedValue::class); + + SimpleDataWithPropertyHooks::from(['constructorProperty' => 'test', 'virtual' => 'other']); + } + + public function testCanHaveANullableComputedValue(): void + { + $dataObject = new class('', '') extends Data { + #[Computed] + public ?string $upper_name; + + /** + * Create the data object and compute its nullable upper-case name. + */ + public function __construct( + public ?string $name, + ) { + $this->upper_name = $name ? strtoupper($name) : null; + } + }; + + $data = $dataObject::from(['name' => 'Ruben']); + + $this->assertSame('Ruben', $data->name); + $this->assertSame('RUBEN', $data->upper_name); + + $data = $dataObject::from(['name' => null]); + + $this->assertNull($data->name); + $this->assertNull($data->upper_name); + + $data = $dataObject::validateAndCreate(['name' => 'Ruben']); + + $this->assertSame('Ruben', $data->name); + $this->assertSame('RUBEN', $data->upper_name); + + $data = $dataObject::validateAndCreate(['name' => null]); + + $this->assertNull($data->name); + $this->assertNull($data->upper_name); + + foreach (['RUBEN', null] as $suppliedValue) { + try { + $dataObject::from(['name' => 'Ruben', 'upper_name' => $suppliedValue]); + $this->fail('Expected the supplied computed value to be rejected.'); + } catch (CannotSetComputedValue) { + } + } + } + + public function testCanHaveAnOptionalComputedValue(): void + { + $dataObject = new class('') extends Data { + #[Computed] + public string|Optional|null $upper_name; + + /** + * Create the data object and compute its upper-case name when one is given. + */ + public function __construct( + public string|Optional $name = new Optional, + ) { + $this->upper_name = $name instanceof Optional ? null : strtoupper($name); + } + }; + + $data = $dataObject::from(['name' => 'Ruben']); + + $this->assertSame('Ruben', $data->name); + $this->assertSame('RUBEN', $data->upper_name); + + $data = $dataObject::from([]); + + $this->assertInstanceOf(Optional::class, $data->name); + $this->assertNull($data->upper_name); + + $this->expectException(CannotSetComputedValue::class); + + $dataObject::from(['name' => 'Ruben', 'upper_name' => 'RUBEN']); + } + + #[DataProvider('constructorFailureMessages')] + public function testThrowsAReadableExceptionMessageWhenTheConstructorFails(array $data, string $message): void + { + try { + MultiData::from($data); + $this->fail('Expected the incomplete payload to be rejected.'); + } catch (CannotCreateData $exception) { + $this->assertSame($message, $exception->getMessage()); + } + } + + /** + * Provide incomplete constructor payloads and their messages. + */ + public static function constructorFailureMessages(): iterable + { + yield 'no params' => [[], 'Could not create data class [' . MultiData::class . ']: its constructor requires 2 payload parameters and 0 were supplied. Parameters missing: first, second.']; + yield 'one param' => [['first' => 'First'], 'Could not create data class [' . MultiData::class . ']: its constructor requires 2 payload parameters and 1 were supplied. Parameters supplied: first. Parameters missing: second.']; + } + + public function testThrowsAReadableExceptionMessageWhenTheArgumentCountErrorExceptionIsThrownInTheConstructor(): void + { + try { + DataWithArgumentCountErrorException::from(['string' => 'string']); + $this->fail('Expected the constructor error to be thrown.'); + } catch (ArgumentCountError $e) { + $constructor = new ReflectionMethod(DataWithArgumentCountErrorException::class, '__construct'); + + $this->assertSame('This function expects exactly 2 arguments, 1 given.', $e->getMessage()); + $this->assertSame(__FILE__, $e->getFile()); + $this->assertGreaterThan($constructor->getStartLine(), $e->getLine()); + $this->assertLessThan($constructor->getEndLine(), $e->getLine()); + } + } + + public function testThrowsAReadableExceptionMessageWhenTheConstructorOfANestedDataObjectFails(): void + { + try { + NestedData::from([ + 'simple' => [], + ]); + $this->fail('Expected the nested payload to be rejected.'); + } catch (CannotCreateData $exception) { + $this->assertSame('Could not create data class [' . SimpleData::class . ']: its constructor requires 1 payload parameters and 0 were supplied. Parameters missing: string.', $exception->getMessage()); + } + } + + public function testACanCreateACollectionOfDataObjects(): void + { + $collectionA = new DataCollection(SimpleData::class, [ + SimpleData::from('A'), + SimpleData::from('B'), + ]); + + $collectionB = SimpleData::collect([ + 'A', + 'B', + ], DataCollection::class); + + $this->assertEquals($collectionA->toArray(), $collectionB->toArray()); + } + + // REMOVED: Custom collection classes for the deprecated collection() method; use collect() with an explicit $into target. + + public function testWillAllowANestedDataObjectToCastPropertiesHoweverItWants(): void + { + $model = new DummyModel(['id' => 10]); + + $withoutModelData = NestedModelData::from([ + 'model' => ['id' => 10], + ]); + + $this->assertInstanceOf(NestedModelData::class, $withoutModelData); + $this->assertEquals(10, $withoutModelData->model->id); + + $withModelData = NestedModelData::from([ + 'model' => $model, + ]); + + $this->assertInstanceOf(NestedModelData::class, $withModelData); + $this->assertEquals(10, $withModelData->model->id); + } + + public function testWillAllowANestedCollectionObjectToCastPropertiesHoweverItWants(): void + { + $data = NestedModelCollectionData::from([ + 'models' => [['id' => 10], ['id' => 20]], + ]); + + $this->assertInstanceOf(NestedModelCollectionData::class, $data); + $this->assertEquals(ModelData::collect([['id' => 10], ['id' => 20]], DataCollection::class), $data->models); + + $data = NestedModelCollectionData::from([ + 'models' => [new DummyModel(['id' => 10]), new DummyModel(['id' => 20])], + ]); + + $this->assertInstanceOf(NestedModelCollectionData::class, $data); + $this->assertEquals(ModelData::collect([['id' => 10], ['id' => 20]], DataCollection::class), $data->models); + + $data = NestedModelCollectionData::from([ + 'models' => ModelData::collect([['id' => 10], ['id' => 20]], DataCollection::class), + ]); + + $this->assertInstanceOf(NestedModelCollectionData::class, $data); + $this->assertEquals(ModelData::collect([['id' => 10], ['id' => 20]], DataCollection::class), $data->models); + } + + public function testWillIgnoreNullOrOptionalValuesWhichAreSetByDefaultInMultiplePayloads(): void + { + $dataClass = new class extends Data { + public string $string; + + public ?string $nullable; + + public Optional|string $optional; + }; + + $data = $dataClass::from( + ['string' => 'string'], + ['nullable' => 'nullable'], + ['optional' => 'optional'] + ); + + $this->assertEquals('string', $data->string); + $this->assertEquals('nullable', $data->nullable); + $this->assertEquals('optional', $data->optional); + + $data = $dataClass::from( + ['optional' => 'optional'], + ['string' => 'string'], + ['nullable' => 'nullable'], + ); + + $this->assertEquals('string', $data->string); + $this->assertEquals('nullable', $data->nullable); + $this->assertEquals('optional', $data->optional); + + $data = $dataClass::from( + ['nullable' => 'nullable'], + ['optional' => 'optional'], + ['string' => 'string'], + ); + + $this->assertEquals('string', $data->string); + $this->assertEquals('nullable', $data->nullable); + $this->assertEquals('optional', $data->optional); + } + + public function testACastCanReturnAnEmptyArray(): void + { + $dataclass = new class extends Data { + #[WithCast(ValueDefinedCast::class, value: [])] + public array $items; + }; + + $data = $dataclass::from([ + 'items' => ['does not matter'], + ]); + + $this->assertEquals([], $data->items); + } + + public function testACastCanReturnNull(): void + { + $dataclass = new class extends Data { + #[WithCast(ValueDefinedCast::class, value: null)] + public ?array $items; + }; + + $data = $dataclass::from([ + 'items' => ['does not matter'], + ]); + + $this->assertNull($data->items); + } + + public function testCanLoopThroughMultipleCastsUntilTheGoodOneIsFound(): void + { + $dataclass = new class extends Data { + public Collection $items; + }; + + $data = $dataclass::factory() + ->withCast(Collection::class, new ValueDefinedCast(Uncastable::create())) + ->withCast(Enumerable::class, new ValueDefinedCast(collect(['Well, hello this cast is used!']))) + ->from(['items' => ['not used']]); + + $this->assertEquals(collect(['Well, hello this cast is used!']), $data->items); + } + + public function testCanInjectADataObjectInAController(): void + { + Route::post('test', TestControllerDataInjection::class); + + $this->postJson(action(TestControllerDataInjection::class), ['string' => 'Hello World'])->assertOk(); + $this->postJson(action(TestControllerDataInjection::class), ['string' => 'Hello World'])->assertOk(); // caused an infinite loop once + } + + public function testCanCollectNullWhenAnOutputTypeIsDefined(): void + { + $this->assertSame([], SimpleData::collect(null, 'array')); + } + + public function testWillCastArrayItemsWhenAnIterableTypeIsDefinedThatCanBeCast(): void + { + $dataClass = new class extends Data { + /** @var array */ + public array $array; + + /** @var Collection */ + public Collection $collection; + }; + + $data = $dataClass::factory() + ->withCast('string', StringToUpperCast::class) + ->from([ + 'array' => ['hello', 'world'], + 'collection' => ['this', 'is', 'great'], + ]); + + $this->assertEquals(['HELLO', 'WORLD'], $data->array); + $this->assertEquals(collect(['THIS', 'IS', 'GREAT']), $data->collection); + } + + #[DefineEnvironment('useMeaningOfLifeStringCast')] + public function testWillCastArrayItemsWhenAnIterableTypeIsDefinedAndPreferItOverAGlobalCast(): void + { + $dataClass = new class extends Data { + /** @var array */ + public array $array; + + /** @var Collection */ + public Collection $collection; + }; + + $data = $dataClass::factory() + ->withCast('string', StringToUpperCast::class) + ->from([ + 'array' => ['hello', 'world'], + 'collection' => ['this', 'is', 'great'], + ]); + + $this->assertEquals(['HELLO', 'WORLD'], $data->array); + $this->assertEquals(collect(['THIS', 'IS', 'GREAT']), $data->collection); + } + + public function testWillCastArrayItemsWhenAnIterableInterfaceTypeIsDefinedThatCanBeCast(): void + { + $dataClass = new class extends Data { + /** @var array */ + public array $dates; + + /** @var array */ + public array $enums; + }; + + $data = $dataClass::factory() + ->withCast('string', StringToUpperCast::class) + ->from([ + 'dates' => [ + '2022-01-18T12:00:00Z', + '2022-01-19T12:00:00Z', + ], + 'enums' => [ + 'boo', + 'foo', + ], + ]); + + $this->assertEquals([ + DateTime::createFromFormat(DATE_ATOM, '2022-01-18T12:00:00Z'), + DateTime::createFromFormat(DATE_ATOM, '2022-01-19T12:00:00Z'), + ], $data->dates); + + $this->assertEquals([ + DummyBackedEnum::BOO, + DummyBackedEnum::FOO, + ], $data->enums); + } + + public function testWillCastIterablesIntoTheCorrectType(): void + { + $dataClass = new class extends Data { + public EloquentCollection $collection; + + public CustomCollection $customCollection; + + public array $array; + }; + + $data = $dataClass::from([ + 'collection' => ['no', 'models', 'here'], + 'customCollection' => ['this', 'is', 'great'], + 'array' => collect(['a', 'collection']), + ]); + + $this->assertInstanceOf(EloquentCollection::class, $data->collection); + $this->assertEquals(new EloquentCollection(['no', 'models', 'here']), $data->collection); + $this->assertInstanceOf(CustomCollection::class, $data->customCollection); + $this->assertEquals(new CustomCollection(['this', 'is', 'great']), $data->customCollection); + $this->assertSame(['a', 'collection'], $data->array); + } + + public function testWillCastAnEmptyIterablesListIntoTheCorrectType(): void + { + $dataClass = new class extends Data { + public EloquentCollection $collection; + + public CustomCollection $customCollection; + + public array $array; + }; + + $data = $dataClass::from([ + 'collection' => [], + 'customCollection' => [], + 'array' => collect([]), + ]); + + $this->assertInstanceOf(EloquentCollection::class, $data->collection); + $this->assertEquals(new EloquentCollection([]), $data->collection); + $this->assertInstanceOf(CustomCollection::class, $data->customCollection); + $this->assertEquals(new CustomCollection([]), $data->customCollection); + $this->assertSame([], $data->array); + } + + public function testWillCastIterablesIntoDefaultTypes(): void + { + $dataClass = new class extends Data { + /** @var array */ + public array $strings; + + /** @var array */ + public array $bools; + + /** @var array */ + public array $ints; + + /** @var array */ + public array $floats; + + /** @var array */ + public array $arrays; + }; + + // Upstream's explicit casts turn every item into some value; Hypervel converts with PHP's weak typing, + // so items it cannot represent, such as 'Hello' for an int or an array for a bool, fail instead. + $deprecations = []; + + set_error_handler(static function (int $severity, string $message) use (&$deprecations): bool { + $deprecations[] = $message; + + return true; + }, E_DEPRECATED); + + try { + $data = $dataClass::from([ + 'strings' => ['Hello', 42, 3.14, true, '0', 'false'], + 'bools' => ['Hello', 42, 3.14, true, '0', 'false'], + 'ints' => [42, '7', 3.14, true, '0'], + 'floats' => [42, 3.14, true, '0'], + 'arrays' => ['Hello', 42, 3.14, true, ['nested'], '0', 'false'], + ]); + } finally { + restore_error_handler(); + } + + $this->assertSame(['Hello', '42', '3.14', '1', '0', 'false'], $data->strings); + $this->assertSame([true, true, true, true, false, false], $data->bools); + $this->assertSame([42, 7, 3, 1, 0], $data->ints); + $this->assertSame([42.0, 3.14, 1.0, 0.0], $data->floats); + $this->assertEquals([['Hello'], [42], [3.14], [true], ['nested'], ['0'], ['false']], $data->arrays); + $this->assertSame(['Implicit conversion from float 3.14 to int loses precision'], $deprecations); + + $empty = ['strings' => [], 'bools' => [], 'ints' => [], 'floats' => [], 'arrays' => []]; + $rejections = [ + ['ints' => ['Hello']], + ['ints' => [['nested']]], + ['ints' => ['false']], + ['floats' => ['Hello']], + ['floats' => [['nested']]], + ['floats' => ['false']], + ['bools' => [['nested']]], + ]; + $rejected = 0; + + foreach ($rejections as $items) { + try { + $dataClass::from([...$empty, ...$items]); + } catch (TypeError) { + ++$rejected; + } + } + + $this->assertSame(count($rejections), $rejected); + } + + // REMOVED: EnumerableCast; the fixed engine converts iterable properties, so its cases below run without registering a cast. + + public function testWillNotCastAnObjectWhichIsAlreadyACollection(): void + { + $dataClass = new class extends Data { + public Collection $collection; + }; + + $collection = collect(['a', 'b']); + + $this->assertSame($collection, $dataClass::from(['collection' => $collection])->collection); + } + + public function testWillCastAnArrayToCollection(): void + { + $dataClass = new class extends Data { + public Collection $collection; + }; + + $this->assertEquals(collect(['a', 'b']), $dataClass::from(['collection' => ['a', 'b']])->collection); + } + + public function testWillCastAnArrayToTheSpecifiedCollectionType(): void + { + $dataClass = new class extends Data { + public LazyCollection $collection; + }; + + $collection = $dataClass::from(['collection' => ['a', 'b']])->collection; + + $this->assertInstanceOf(LazyCollection::class, $collection); + $this->assertSame(['a', 'b'], $collection->all()); + } + + public function testKeepsAnArrayAcceptedByAContainerUnion(): void + { + $dataClass = new class extends Data { + public Collection|array $collection; + }; + + // An array already satisfies the declared union, so it is kept rather than converted. + $this->assertSame(['a', 'b'], $dataClass::from(['collection' => ['a', 'b']])->collection); + $this->assertEquals(collect(['a', 'b']), $dataClass::from(['collection' => collect(['a', 'b'])])->collection); + } + + public function testWillNeverInterveneWithDataCollections(): void + { + $data = TestDataCollectionCastWithDataCollectable::from([ + 'collection' => ['a', 'b'], + ]); + + $this->assertEquals(collect([ + SimpleData::fromString('a'), + SimpleData::fromString('b'), + ]), $data->collection); + } + + // REMOVED: Creation-context path tracking through custom data pipes; the fixed engine tracks paths internally (ConstructionStateTest). + + public function testIsPossibleToCreateAnUnionTypeDataObject(): void + { + $dataClass = new class extends Data { + public string|SimpleData $property; + }; + + // An accepted scalar keeps its union arm instead of becoming a data object. + $this->assertSame('Hello World', $dataClass::from(['property' => 'Hello World'])->property); + + $dataClass = new class extends Data { + public int|SimpleData $property; + }; + + $this->assertIsInt($dataClass::from(['property' => 10])->property); + $this->assertInstanceOf(SimpleData::class, $dataClass::from(['property' => 'Hello World'])->property); + + $dataClass = new class extends Data { + public int|SimpleData|Optional|Lazy $property; + }; + + $this->assertIsInt($dataClass::from(['property' => 10])->property); + $this->assertInstanceOf(SimpleData::class, $dataClass::from(['property' => 'Hello World'])->property); + $this->assertInstanceOf(Lazy::class, $dataClass::from(['property' => Lazy::create(fn (): int => 10)])->property); + $this->assertInstanceOf(Optional::class, $dataClass::from([])->property); + } + + // REMOVED: 'is possible to create a union type data collectable'; unfinished upstream (->todo()), and creating items whose type mixes a Data class with other types is not supported. Transforming them is (DataTransformerTest). + + public function testCanBeCreatedWithoutOptionalValues(): void + { + $dataClass = new class extends Data { + public string $name; + + public string|Optional|null $description; + + public int|Optional $year = 2025; + + public string|Optional $slug; + }; + + $data = $dataClass::factory() + ->withoutOptionalValues() + ->from([ + 'name' => 'Ruben', + ]); + + $this->assertSame('Ruben', $data->name); + $this->assertNull($data->description); + $this->assertSame(2025, $data->year); + // Upstream leaves a property that cannot hold null uninitialized; Hypervel keeps it Optional. + $this->assertInstanceOf(Optional::class, $data->slug); + + $this->assertSame([ + 'name' => 'Ruben', + 'description' => null, + 'year' => 2025, + ], $data->toArray()); + } + + public function testWithoutOptionalValuesAppliesToConstructorNestedAndCastInputs(): void + { + OptionalValuesCast::$received = null; + + $direct = OptionalValuesData::factory() + ->withoutOptionalValues() + ->from(['name' => 'Ruben', 'child' => []]); + $general = OptionalValuesData::factory() + ->withoutOptionalValues() + ->beforeCreation(static fn (array $properties): array => $properties) + ->from(['name' => 'Ruben', 'child' => []]); + $cast = OptionalValuesCastData::factory() + ->withoutOptionalValues() + ->from(['name' => 'Ruben']); + + foreach ([$direct, $general] as $data) { + $this->assertNull($data->description); + $this->assertInstanceOf(Optional::class, $data->slug); + $this->assertNull($data->child->note); + } + + $this->assertSame('RUBEN', $cast->name); + $this->assertNull(OptionalValuesCast::$received['description']); + $this->assertInstanceOf(Optional::class, OptionalValuesData::from(['name' => 'Ruben', 'child' => []])->description); + } + + public function testCanCreateADataObjectWithAutoLazyProperties(): void + { + $dataClass = new class extends Data { + #[AutoLazy] + public Lazy|SimpleData $data; + + /** @var Collection|Lazy */ + #[AutoLazy] + public Lazy|Collection $dataCollection; + + #[AutoLazy] + public Lazy|string $string; + + #[AutoLazy] + public Lazy|string $overwrittenLazy; + + #[AutoLazy] + public Optional|Lazy|string $optionalLazy; + + #[AutoLazy] + public string|Lazy|null $nullableLazy; + }; + + $data = $dataClass::from([ + 'data' => 'Hello World', + 'dataCollection' => ['Hello', 'World'], + 'string' => 'Hello World', + 'overwrittenLazy' => Lazy::create(fn (): string => 'Overwritten Lazy'), + ]); + + $this->assertInstanceOf(Lazy::class, $data->data); + $this->assertInstanceOf(Lazy::class, $data->dataCollection); + $this->assertInstanceOf(Lazy::class, $data->string); + $this->assertInstanceOf(Lazy::class, $data->overwrittenLazy); + $this->assertInstanceOf(Optional::class, $data->optionalLazy); + $this->assertNull($data->nullableLazy); + + $this->assertSame([ + 'nullableLazy' => null, + ], $data->toArray()); + + $this->assertSame([ + 'data' => ['string' => 'Hello World'], + 'dataCollection' => [ + ['string' => 'Hello'], + ['string' => 'World'], + ], + 'string' => 'Hello World', + 'overwrittenLazy' => 'Overwritten Lazy', + 'nullableLazy' => null, + ], $data->include('data', 'dataCollection', 'string', 'overwrittenLazy')->toArray()); + } + + public function testCanCreateAnAutoLazyClassLevelAttributeClass(): void + { + $data = TestAutoLazyClassAttributeData::from([ + 'data' => 'Hello World', + 'dataCollection' => ['Hello', 'World'], + 'string' => 'Hello World', + 'overwrittenLazy' => Lazy::create(fn (): string => 'Overwritten Lazy'), + 'regularString' => 'Hello World', + ]); + + $this->assertInstanceOf(Lazy::class, $data->data); + $this->assertInstanceOf(Lazy::class, $data->dataCollection); + $this->assertInstanceOf(Lazy::class, $data->string); + $this->assertInstanceOf(Lazy::class, $data->overwrittenLazy); + $this->assertInstanceOf(Optional::class, $data->optionalLazy); + $this->assertNull($data->nullableLazy); + $this->assertSame('Hello World', $data->regularString); + + $this->assertSame([ + 'nullableLazy' => null, + 'regularString' => 'Hello World', + ], $data->toArray()); + $this->assertSame([ + 'data' => ['string' => 'Hello World'], + 'dataCollection' => [ + ['string' => 'Hello'], + ['string' => 'World'], + ], + 'string' => 'Hello World', + 'overwrittenLazy' => 'Overwritten Lazy', + 'nullableLazy' => null, + 'regularString' => 'Hello World', + ], $data->include('data', 'dataCollection', 'string', 'overwrittenLazy')->toArray()); + } + + public function testCanUseAutoLazyToConstructAnInertiaLazy(): void + { + $dataClass = new class extends Data { + #[AutoInertiaLazy] + public string|Lazy $string; + }; + + $data = $dataClass::from(['string' => 'Hello World']); + + $this->assertInstanceOf(InertiaLazy::class, $data->string); + $this->assertInstanceOf(OptionalProp::class, $data->toArray()['string']); + } + + public function testCanUseAutoLazyToConstructAClosureLazy(): void + { + $dataClass = new class extends Data { + #[AutoClosureLazy] + public string|Lazy $string; + }; + + $data = $dataClass::from(['string' => 'Hello World']); + + $this->assertInstanceOf(ClosureLazy::class, $data->string); + $this->assertInstanceOf(Closure::class, $data->toArray()['string']); + } + + public function testCanUseAutoLazyToConstructAWhenLoadedLazy(): void + { + $dataClass = new class extends Data { + #[AutoWhenLoadedLazy] + /** @var array */ + public array|Lazy $fakeNestedModels; + }; + + $model = FakeModel::factory() + ->has(FakeNestedModel::factory()->count(2)) + ->create(); + + $this->assertEmpty($dataClass::from($model)->all()); + + $model->load('fakeNestedModels'); + + $models = $dataClass::from($model)->all()['fakeNestedModels']; + + $this->assertIsArray($models); + $this->assertCount(2, $models); + $this->assertContainsOnlyInstancesOf(FakeNestedModelData::class, $models); + } + + public function testCanUseAutoLazyToConstructAWhenLoadedLazyWithAManualDefinedRelation(): void + { + $dataClass = new class extends Data { + #[AutoWhenLoadedLazy('fakeNestedModels')] + /** @var array */ + public array|Lazy $models; + }; + + $model = FakeModel::factory() + ->has(FakeNestedModel::factory()->count(2)) + ->create(); + + $this->assertEmpty($dataClass::from($model)->all()); + + $model->load('fakeNestedModels'); + + $models = $dataClass::from($model)->all()['models']; + + $this->assertIsArray($models); + $this->assertCount(2, $models); + $this->assertContainsOnlyInstancesOf(FakeNestedModelData::class, $models); + } + + public function testCanUseAutoLazyToConstructADataObjectWithPropertyPromotion(): void + { + $dataClass = new class([]) extends Data { + /** + * @param array $fakeNestedModels + */ + public function __construct( + #[AutoWhenLoadedLazy] + public array|Lazy $fakeNestedModels + ) { + } + }; + + $model = FakeModel::factory() + ->has(FakeNestedModel::factory()->count(2)) + ->create(); + + $this->assertEmpty($dataClass::from($model)->all()); + + $model->load('fakeNestedModels'); + + $models = $dataClass::from($model)->all()['fakeNestedModels']; + + $this->assertIsArray($models); + $this->assertCount(2, $models); + $this->assertContainsOnlyInstancesOf(FakeNestedModelData::class, $models); + } + + public function testCanCreateADataObjectWithInertiaDeferredProperties(): void + { + $dataClass = new class extends Data { + public InertiaDeferred|string $deferred; + + public InertiaDeferred|string $deferredWithGroup; + + /** + * Create the data object without constructor values. + */ + public function __construct() + { + } + }; + + $data = $dataClass::from([ + 'deferred' => Lazy::inertiaDeferred(Inertia::defer(fn (): string => 'Deferred Value')), + 'deferredWithGroup' => Lazy::inertiaDeferred(Inertia::defer(fn (): string => 'Deferred Value', 'deferred-group')), + ]); + + $this->assertDeferredProperties($data); + } + + public function testCanCreateADataObjectWithInertiaDeferredClosure(): void + { + $dataClass = new class extends Data { + public InertiaDeferred|string $deferred; + + public InertiaDeferred|string $deferredWithGroup; + + /** + * Create the data object without constructor values. + */ + public function __construct() + { + } + }; + + $data = $dataClass::from([ + 'deferred' => Lazy::inertiaDeferred(fn (): string => 'Deferred Value'), + 'deferredWithGroup' => Lazy::inertiaDeferred(fn (): string => 'Deferred Value', 'deferred-group'), + ]); + + $this->assertDeferredProperties($data); + } + + public function testCanCreateADataObjectWithInertiaDeferredValue(): void + { + $dataClass = new class extends Data { + public InertiaDeferred|string $deferred; + + public InertiaDeferred|string $deferredWithGroup; + + /** + * Create the data object without constructor values. + */ + public function __construct() + { + } + }; + + $data = $dataClass::from([ + 'deferred' => Lazy::inertiaDeferred('Deferred Value'), + 'deferredWithGroup' => Lazy::inertiaDeferred('Deferred Value', 'deferred-group'), + ]); + + $this->assertDeferredProperties($data); + } + + public function testCanUseAutoDeferredToConstructAInertiaDeferredProperty(): void + { + $dataClass = new class extends Data { + #[AutoInertiaDeferred] + public InertiaDeferred|string $string; + + #[AutoInertiaDeferred('deferred-group')] + public InertiaDeferred|string $deferredWithGroup; + }; + + $data = $dataClass::from(['string' => 'Deferred Value', 'deferredWithGroup' => 'Deferred Value']); + + $this->assertInstanceOf(InertiaDeferred::class, $data->string); + $this->assertInstanceOf(DeferProp::class, $data->all()['string']); + $this->assertSame('Deferred Value', $data->all()['string']()); + + $this->assertInstanceOf(InertiaDeferred::class, $data->deferredWithGroup); + $this->assertInstanceOf(DeferProp::class, $data->all()['deferredWithGroup']); + $this->assertSame('Deferred Value', $data->all()['deferredWithGroup']()); + $this->assertSame('deferred-group', $data->all()['deferredWithGroup']->group()); + } + + public function testCanUseClassLevelAutoDeferredToConstructAInertiaDeferredProperty(): void + { + $data = AutoDeferredData::from(['string' => 'Deferred Value']); + + $this->assertInstanceOf(InertiaDeferred::class, $data->string); + $this->assertInstanceOf(DeferProp::class, $data->all()['string']); + $this->assertSame('Deferred Value', $data->all()['string']()); + } + + public function testWillAllowPropertyMorphableDataToBeCreated(): void + { + $dataA = AbstractPropertyMorphableData::from([ + 'variant' => 'a', + 'a' => 'foo', + 'enum' => 'foo', + ]); + + $this->assertInstanceOf(PropertyMorphableDataA::class, $dataA); + $this->assertEquals(PropertyMorphableEnum::A, $dataA->variant); + $this->assertEquals('foo', $dataA->a); + $this->assertEquals(DummyBackedEnum::FOO, $dataA->enum); + + $dataB = AbstractPropertyMorphableData::from([ + 'variant' => 'b', + 'b' => 'bar', + ]); + + $this->assertInstanceOf(PropertyMorphableDataB::class, $dataB); + $this->assertEquals(PropertyMorphableEnum::B, $dataB->variant); + $this->assertEquals('bar', $dataB->b); + } + + public function testWillAllowPropertyMorphableDataToBeCreatedFromConcrete(): void + { + $dataA = PropertyMorphableDataA::from([ + 'a' => 'foo', + 'enum' => 'foo', + ]); + + $this->assertInstanceOf(PropertyMorphableDataA::class, $dataA); + $this->assertEquals(PropertyMorphableEnum::A, $dataA->variant); + $this->assertEquals('foo', $dataA->a); + $this->assertEquals(DummyBackedEnum::FOO, $dataA->enum); + } + + public function testConcretePropertyMorphableDataKeepsTheDiscriminatorItsConstructorAssigns(): void + { + $data = PropertyMorphableDataA::from([ + 'variant' => 'b', + 'a' => 'foo', + 'enum' => 'foo', + ]); + + $this->assertInstanceOf(PropertyMorphableDataA::class, $data); + $this->assertSame(PropertyMorphableEnum::A, $data->variant); + } + + public function testWillAllowPropertyMorphableDataToBeCreatedFromANestedCollection(): void + { + $data = NestedPropertyMorphableData::from([ + 'nestedCollection' => [ + ['variant' => 'a', 'a' => 'foo', 'enum' => 'foo'], + ['variant' => 'b', 'b' => 'bar'], + ], + ]); + + $this->assertInstanceOf(PropertyMorphableDataA::class, $data->nestedCollection[0]); + $this->assertEquals(PropertyMorphableEnum::A, $data->nestedCollection[0]->variant); + $this->assertEquals('foo', $data->nestedCollection[0]->a); + $this->assertEquals(DummyBackedEnum::FOO, $data->nestedCollection[0]->enum); + + $this->assertInstanceOf(PropertyMorphableDataB::class, $data->nestedCollection[1]); + $this->assertEquals(PropertyMorphableEnum::B, $data->nestedCollection[1]->variant); + $this->assertEquals('bar', $data->nestedCollection[1]->b); + } + + public function testWillAllowPropertyMorphableDataToBeCreatedAsACollection(): void + { + $collection = AbstractPropertyMorphableData::collect([ + ['variant' => 'a', 'a' => 'foo', 'enum' => DummyBackedEnum::FOO->value], + ['variant' => 'b', 'b' => 'bar'], + ]); + + $this->assertInstanceOf(PropertyMorphableDataA::class, $collection[0]); + $this->assertEquals(PropertyMorphableEnum::A, $collection[0]->variant); + $this->assertEquals('foo', $collection[0]->a); + $this->assertEquals(DummyBackedEnum::FOO, $collection[0]->enum); + + $this->assertInstanceOf(PropertyMorphableDataB::class, $collection[1]); + $this->assertEquals(PropertyMorphableEnum::B, $collection[1]->variant); + $this->assertEquals('bar', $collection[1]->b); + } + + public function testWillOnlyNormalizePayloadsWhenAPropertyMorphableDataClassIsSelected(): void + { + $data = TestMorphableDataWithSpecialNormalizerAbstract::from([ + 'type' => 'specific', + 'name' => 'Hello World', + ]); + + $this->assertInstanceOf(TestMorphableDataWithSpecialNormalizerSpecific::class, $data); + $this->assertEquals('Hello World', $data->name); + $this->assertEquals('specific', $data->type); + } + + public function testWillAllowPropertyMorphableDataToBeCreatedFromADefault(): void + { + $dataA = TestAbstractPropertyMorphableDefaultData::from([ + 'a' => 'foo', + 'enum' => 'foo', + ]); + + $this->assertInstanceOf(TestPropertyMorphableDefaultDataA::class, $dataA); + $this->assertEquals(PropertyMorphableEnum::A, $dataA->variant); + $this->assertEquals('foo', $dataA->a); + $this->assertEquals(DummyBackedEnum::FOO, $dataA->enum); + } + + public function testWillThrowAnExceptionWhenAPropertyMorphableDataClassIsNotFound(): void + { + $this->expectException(CannotCreateAbstractClass::class); + + AbstractPropertyMorphableData::from([ + 'variant' => 'c', + ]); + } + + /** + * Assert the deferred properties resolve to grouped Inertia deferred props. + */ + protected function assertDeferredProperties(Data $data): void + { + $this->assertInstanceOf(InertiaDeferred::class, $data->deferred); + $this->assertInstanceOf(DeferProp::class, $data->all()['deferred']); + $this->assertSame('Deferred Value', $data->all()['deferred']()); + + $this->assertInstanceOf(InertiaDeferred::class, $data->deferredWithGroup); + $this->assertInstanceOf(DeferProp::class, $data->all()['deferredWithGroup']); + $this->assertSame('Deferred Value', $data->all()['deferredWithGroup']()); + $this->assertSame('deferred-group', $data->all()['deferredWithGroup']->group()); + } + + /** + * Ignore supplied computed property values. + */ + protected function ignoreSuppliedComputedValues(Application $app): void + { + $app->make('config')->set('data.features.ignore_exception_when_trying_to_set_computed_property_value', true); + } + + /** + * Register a global string cast. + */ + protected function useMeaningOfLifeStringCast(Application $app): void + { + $app->make('config')->set('data.casts', [ + 'string' => MeaningOfLifeCast::class, + ]); + } +} + +class ComputedFullNameData extends Data +{ + #[Computed] + public string $full_name; + + /** + * Create a fixture with a computed full name. + */ + public function __construct( + public string $first_name, + public string $last_name, + ) { + $this->full_name = "{$this->first_name} {$this->last_name}"; + } +} + +class SimpleDataWithoutConstructor extends Data +{ + public string $string; + + /** + * Create the fixture from a string. + */ + public static function fromString(string $string): self + { + $data = new self; + + $data->string = $string; + + return $data; + } +} + +class ConfidentialDataCollectionCast implements Cast +{ + /** + * Replace every item with confidential data. + */ + public function cast(DataProperty $property, mixed $value, array $properties, CreationContext $context): array + { + return array_map(fn (): SimpleData => SimpleData::from('CONFIDENTIAL'), $value); + } +} + +class SimpleCastable implements Castable +{ + /** + * Create a castable fixture. + */ + public function __construct(public string $value) + { + } + + /** + * Create the cast for this type. + */ + public static function dataCastUsing(array $arguments): Cast + { + return new class implements Cast { + /** + * Cast the value into the castable type. + */ + public function cast(DataProperty $property, mixed $value, array $properties, CreationContext $context): SimpleCastable + { + return new SimpleCastable($value); + } + }; + } +} + +class ContextAwareCast implements Cast +{ + /** + * Append the declared property values to the value. + */ + public function cast(DataProperty $property, mixed $value, array $properties, CreationContext $context): string + { + return $value . '+' . json_encode($properties); + } +} + +class ValueDefinedCast implements Cast +{ + /** + * Create a cast that always returns the given value. + */ + public function __construct( + private mixed $value + ) { + } + + /** + * Return the defined value. + */ + public function cast(DataProperty $property, mixed $value, array $properties, CreationContext $context): mixed + { + return $this->value; + } +} + +class DataWithArgumentCountErrorException extends Data +{ + /** + * Create a fixture whose constructor throws an argument count error. + */ + public function __construct( + public string $string, + public string $optional = 'default', + ) { + throw new ArgumentCountError('This function expects exactly 2 arguments, 1 given.'); + } +} + +class ModelData extends Data +{ + /** + * Create a model data fixture. + */ + public function __construct( + public int $id + ) { + } + + /** + * Create the fixture from a dummy model. + */ + public static function fromDummyModel(DummyModel $model): self + { + return new self($model->id); + } +} + +class NestedModelData extends Data +{ + /** + * Create a fixture with nested model data. + */ + public function __construct( + public ModelData $model + ) { + } +} + +class NestedModelCollectionData extends Data +{ + /** + * Create a fixture with a nested model data collection. + */ + public function __construct( + /** @var ModelData[] */ + public DataCollection $models + ) { + } +} + +class TestControllerDataInjection +{ + /** + * Handle the request with an injected data object. + */ + public function __invoke(SimpleData $data): Response + { + return response('ok'); + } +} + +class TestDataCollectionCastWithDataCollectable extends Data +{ + /** @var Collection */ + public Collection $collection; +} + +#[AutoLazy] +class TestAutoLazyClassAttributeData extends Data +{ + public Lazy|SimpleData $data; + + /** @var Collection|Lazy */ + public Lazy|Collection $dataCollection; + + public Lazy|string $string; + + public Lazy|string $overwrittenLazy; + + public Optional|Lazy|string $optionalLazy; + + public string|Lazy|null $nullableLazy; + + public string $regularString; +} + +#[AutoInertiaDeferred] +class AutoDeferredData extends Data +{ + public InertiaDeferred|string $string; +} + +class NestedPropertyMorphableData extends Data +{ + /** + * Create a fixture with a nested property-morphable collection. + */ + public function __construct( + /** @var AbstractPropertyMorphableData[] */ + public ?DataCollection $nestedCollection, + ) { + } +} + +abstract class TestMorphableDataWithSpecialNormalizerAbstract extends Data implements PropertyMorphableData +{ + /** + * Create a property-morphable fixture with a class normalizer. + */ + public function __construct( + #[PropertyForMorph] + public string $type, + ) { + } + + /** + * Resolve the concrete class for the type. + */ + public static function morph(array $properties): ?string + { + return match ($properties['type']) { + 'specific' => TestMorphableDataWithSpecialNormalizerSpecific::class, + default => null, + }; + } + + /** + * Get the class-owned data normalizers. + */ + public static function normalizers(): array + { + return [FakeNormalizer::returnsArrayItem()]; + } +} + +class TestMorphableDataWithSpecialNormalizerSpecific extends TestMorphableDataWithSpecialNormalizerAbstract +{ + /** + * Create the specific morph variant. + */ + public function __construct( + public string $name, + ) { + parent::__construct('specific'); + } +} + +class FakeNormalizer implements Normalizer +{ + /** + * Create a normalizer that reads properties from array items. + */ + public static function returnsArrayItem(): self + { + return new self; + } + + /** + * Normalize an array into a property reader. + */ + public function normalize(mixed $value): array|Normalized|null + { + return new class($value) implements Normalized { + /** + * Create the property reader for the array. + */ + public function __construct( + protected array $value, + ) { + } + + /** + * Get a property value from the array. + */ + public function getProperty(string $name, DataProperty $dataProperty): mixed + { + return $this->value[$name]; + } + }; + } +} + +abstract class TestAbstractPropertyMorphableDefaultData extends Data implements PropertyMorphableData +{ + /** + * Create a property-morphable fixture with a default variant. + */ + public function __construct( + #[PropertyForMorph] + public PropertyMorphableEnum $variant = PropertyMorphableEnum::A, + ) { + } + + /** + * Resolve the concrete class for the variant. + */ + public static function morph(array $properties): ?string + { + return match ($properties['variant'] ?? null) { + PropertyMorphableEnum::A => TestPropertyMorphableDefaultDataA::class, + default => null, + }; + } +} + +class TestPropertyMorphableDefaultDataA extends TestAbstractPropertyMorphableDefaultData +{ + /** + * Create the default morph variant. + */ + public function __construct(public string $a, public DummyBackedEnum $enum) + { + parent::__construct(PropertyMorphableEnum::A); + } +} + +class OptionalValuesData extends Data +{ + /** + * Create a fixture with missing Optional constructor properties. + */ + public function __construct( + public string $name, + public string|Optional|null $description, + public string|Optional $slug, + public OptionalValuesChildData $child, + ) { + } +} + +class OptionalValuesChildData extends Data +{ + /** + * Create a nested fixture with a missing nullable Optional property. + */ + public function __construct( + public string|Optional|null $note, + ) { + } +} + +class OptionalValuesCastData extends Data +{ + /** + * Create a fixture whose cast receives the declared property values. + */ + public function __construct( + #[WithCast(OptionalValuesCast::class)] + public string $name, + public string|Optional|null $description, + ) { + } +} + +class OptionalValuesCast implements Cast +{ + /** @var null|array */ + public static ?array $received = null; + + /** + * Record the declared values and uppercase the value. + */ + public function cast(DataProperty $property, mixed $value, array $properties, CreationContext $context): string + { + static::$received = $properties; + + return strtoupper($value); + } +} diff --git a/tests/Data/DataCollectionTest.php b/tests/Data/DataCollectionTest.php index 21763763ae..5bd60c4eb1 100644 --- a/tests/Data/DataCollectionTest.php +++ b/tests/Data/DataCollectionTest.php @@ -15,15 +15,19 @@ use Hypervel\Data\Normalizers\Normalizer; use Hypervel\Data\PaginatedDataCollection; use Hypervel\Pagination\CursorPaginator; +use Hypervel\Pagination\LengthAwarePaginator; use Hypervel\Pagination\Paginator; use Hypervel\Support\LazyCollection; use Hypervel\Testbench\TestCase; +use Hypervel\Tests\Data\Fixtures\Collections\CustomCollection; +use Hypervel\Tests\Data\Fixtures\DummyDto; +use Hypervel\Tests\Data\Fixtures\LazyData; +use Hypervel\Tests\Data\Fixtures\MultiLazyData; +use Hypervel\Tests\Data\Fixtures\SimpleData; use RuntimeException; class DataCollectionTest extends TestCase { - // REMOVED: Deprecated Data::collection() and Enumerable forwarding tests; use collect() and toCollection(). - /** * Get package providers for the test application. */ @@ -32,6 +36,200 @@ protected function getPackageProviders(Application $app): array return [DataServiceProvider::class]; } + // REMOVED: 'can filter a collection' and 'can reject items within a collection'; DataCollection's filter() and reject() are deprecated upstream, so use toCollection(). + + public function testItCanPutItemsThroughAPaginatedDataCollection(): void + { + $collection = new PaginatedDataCollection( + SimpleData::class, + new LengthAwarePaginator(['A', 'B'], 2, 15), + ); + + $filtered = $collection->through(fn (SimpleData $data): SimpleData => new SimpleData("{$data->string}x"))->toArray(); + + $this->assertSame([ + ['string' => 'Ax'], + ['string' => 'Bx'], + ], $filtered['data']); + } + + public function testIsIteratable(): void + { + $collection = new DataCollection(SimpleData::class, [ + 'A', 'B', 'C', 'D', + ]); + + $letters = []; + + foreach ($collection as $item) { + $letters[] = $item->string; + } + + $this->assertSame(['A', 'B', 'C', 'D'], $letters); + } + + public function testHasArrayAccess(): void + { + $collection = SimpleData::collect([ + 'A', 'B', SimpleData::from('C'), SimpleData::from('D'), + ], DataCollection::class); + + // Count + $this->assertCount(4, $collection); + + // Offset exists + $this->assertNotEmpty($collection[3]); + $this->assertTrue(empty($collection[5])); + + // Offset get + $this->assertEquals(SimpleData::from('A'), $collection[0]); + $this->assertEquals(SimpleData::from('D'), $collection[3]); + + // Offset set + $collection[2] = 'And now something completely different'; + $collection[4] = 'E'; + + $this->assertEquals(SimpleData::from('And now something completely different'), $collection[2]); + $this->assertEquals(SimpleData::from('E'), $collection[4]); + + // Offset unset + unset($collection[4]); + + $this->assertCount(4, $collection); + } + + public function testCanUpdateDataPropertiesWithinACollection(): void + { + LazyData::setAllowedIncludes(null); + + $collection = new DataCollection(LazyData::class, [ + LazyData::from('Never gonna give you up!'), + ]); + + $this->assertSame([ + ['name' => 'Never gonna give you up!'], + ], $collection->include('name')->toArray()); + + $collection[0]->name = 'Giving Up on Love'; + + $this->assertSame([ + ['name' => 'Giving Up on Love'], + ], $collection->include('name')->toArray()); + + $collection[] = LazyData::from('Cry for help'); + + $this->assertSame([ + ['name' => 'Giving Up on Love'], + ['name' => 'Cry for help'], + ], $collection->include('name')->toArray()); + + unset($collection[0]); + + $this->assertSame([ + 1 => ['name' => 'Cry for help'], + ], $collection->include('name')->toArray()); + } + + public function testCanCreateADataCollectionFromALazyCollection(): void + { + $lazyCollection = new LazyCollection(function (): iterable { + $items = [ + 'Never gonna give you up!', + 'Giving Up on Love', + ]; + + foreach ($items as $item) { + yield $item; + } + }); + + $collection = new DataCollection(SimpleData::class, $lazyCollection); + + $this->assertEquals([ + SimpleData::from('Never gonna give you up!'), + SimpleData::from('Giving Up on Love'), + ], $collection->items()); + + // REMOVED: upstream's following through()->filter() chain; both proxies are deprecated upstream, so use toCollection(). + } + + public function testCanConvertADataCollectionIntoAHypervelCollection(): void + { + $this->assertEquals( + collect([ + SimpleData::from('A'), + SimpleData::from('B'), + SimpleData::from('C'), + ]), + (new DataCollection(SimpleData::class, ['A', 'B', 'C']))->toCollection(), + ); + } + + // REMOVED: 'can reset the keys' and the two 'can return a sole data object' cases; DataCollection's values() and sole() are deprecated upstream, so use toCollection(). + + public function testACollectionCanBeMerged(): void + { + $collectionA = SimpleData::collect(collect(['A', 'B'])); + $collectionB = SimpleData::collect(collect(['C', 'D'])); + + $this->assertSame([ + ['string' => 'A'], + ['string' => 'B'], + ['string' => 'C'], + ['string' => 'D'], + ], $collectionA->merge($collectionB)->toArray()); + } + + public function testCanUseACustomCollectionExtendedFromCollectionToCollectACollectionOfDataObjects(): void + { + $collection = SimpleData::collect(new CustomCollection([ + ['string' => 'A'], + ['string' => 'B'], + ])); + + $this->assertInstanceOf(CustomCollection::class, $collection); + $this->assertInstanceOf(SimpleData::class, $collection[0]); + $this->assertInstanceOf(SimpleData::class, $collection[1]); + } + + public function testDoesNotMutateWrappedPaginatorsDuringTransformation(): void + { + $paginatorOfSimpleData = new Paginator([ + ['string' => 'A'], + ['string' => 'B'], + ], perPage: 15); + + $collection = SimpleData::collect($paginatorOfSimpleData, PaginatedDataCollection::class); + + $expect = [ + ['string' => 'A'], + ['string' => 'B'], + ]; + + // Perform the transformation twice, the second should not throw + $this->assertSame($expect, $collection->toArray()['data']); + $this->assertSame($expect, $collection->toArray()['data']); + } + + public function testItCanIncludeLazyItemsThroughAPaginatedDataCollection(): void + { + // https://github.com/spatie/laravel-data/issues/746 + $collection = new PaginatedDataCollection( + MultiLazyData::class, + new LengthAwarePaginator([ + DummyDto::rick(), + DummyDto::bon(), + ], 2, 15), + ); + + $filtered = $collection->through(fn (MultiLazyData $data): MultiLazyData => $data->include('artist'))->toArray(); + + $this->assertSame([ + ['artist' => 'Rick Astley'], + ['artist' => 'Bon Jovi'], + ], $filtered['data']); + } + public function testConstructorUsesOneRootOperationAndRetainsNamedItemFactories(): void { CollectionNormalizerData::$normalizerCalls = 0; @@ -78,22 +276,66 @@ public function testConstructorAndOffsetSetBypassAnOverriddenPublicFromMethod(): $this->assertSame(2, $collection['second']->id); } - public function testKeyedAndIteratorReadsDoNotConsumeOrPropagatePartials(): void + public function testKeyedAndIteratorReadsCopyPartialsWithoutConsumingThem(): void { $first = new CollectionPartialData(1, 'first'); $second = new CollectionPartialData(2, 'second'); - $collection = new DataCollection(CollectionPartialData::class, [$first, $second]); - $collection->include('name'); + $collection = new DataCollection(CollectionPartialData::class, ['a' => $first, 'b' => $second]); + $collection->only('name'); - $this->assertSame($first, $collection[0]); + $this->assertSame($first, $collection['a']); + $this->assertSame(['name' => 'first'], $first->toArray()); + $this->assertSame(['id' => 1, 'name' => 'first'], $first->toArray()); - foreach ($collection as $item) { - $this->assertTrue(in_array($item, [$first, $second], true)); - } + $this->assertSame(['a' => $first, 'b' => $second], iterator_to_array($collection)); + $this->assertSame(['name' => 'second'], $second->toArray()); - $this->assertTrue($first->getPartialsDefinition()->isEmpty()); - $this->assertTrue($second->getPartialsDefinition()->isEmpty()); - $this->assertFalse($collection->getPartialsDefinition()->isEmpty()); + $this->assertSame(['a' => ['name' => 'first'], 'b' => ['name' => 'second']], $collection->toArray()); + $this->assertSame([ + 'a' => ['id' => 1, 'name' => 'first'], + 'b' => ['id' => 2, 'name' => 'second'], + ], $collection->toArray()); + } + + public function testRepeatedReadsAndAllCallsCopyPartialsOnce(): void + { + $item = new CollectionPartialData(1, 'first'); + $collection = new DataCollection(CollectionPartialData::class, [$item]); + $collection->onlyPermanently('name'); + + $this->assertSame($item, $collection[0]); + $this->assertSame($item, $collection[0]); + $this->assertSame([$item], iterator_to_array($collection)); + $this->assertSame([$item], $collection->all()); + + $this->assertCount(1, $item->getPartialsDefinition()->resolve($item)['only']); + $this->assertSame(['name' => 'first'], $item->toArray()); + } + + public function testPaginatedTransformationReadsItemsWithoutCopyingPartials(): void + { + $calls = 0; + $collection = new PaginatedDataCollection(CollectionPartialData::class, new Paginator( + [['id' => '1', 'name' => 'first'], ['id' => '2', 'name' => 'second']], + 15, + 1, + ['path' => '/items'], + )); + $collection->onlyWhen('name', static function () use (&$calls): bool { + ++$calls; + + return true; + }, permanent: true); + [$first, $second] = $collection->items()->items(); + + $this->assertSame([['name' => 'first'], ['name' => 'second']], $collection->toArray()['data']); + $this->assertSame(1, $calls); + $this->assertFalse($first->hasPartialsDefinition()); + $this->assertFalse($second->hasPartialsDefinition()); + + $this->assertSame([$first, $second], iterator_to_array($collection)); + $this->assertSame(2, $calls); + $this->assertSame(['name' => 'second'], $second->toArray()); } public function testPaginatedCollectionOwnsItsPaginatorAndThroughReturnsAnIndependentClone(): void @@ -120,8 +362,8 @@ public function testPaginatedCollectionOwnsItsPaginatorAndThroughReturnsAnIndepe $this->assertSame(2, $mapped->items()->currentPage()); $this->assertSame('/items', $mapped->items()->path()); $this->assertSame([['id' => 1, 'name' => 'first']], $collection->toArray()['data']); - $this->assertSame(2, $collection->toArray()['current_page']); - $this->assertSame('/items', $collection->toArray()['path']); + $this->assertSame(2, $collection->toArray()['meta']['current_page']); + $this->assertSame('/items', $collection->toArray()['meta']['path']); $this->assertCount(1, $collection); $this->expectException(PaginatedCollectionIsAlwaysWrapped::class); @@ -144,8 +386,8 @@ public function testCursorPaginatedCollectionOwnsAndTransformsItsPaginator(): vo $this->assertSame(1, $collection->items()->items()[0]->id); $this->assertSame('/items', $collection->items()->path()); $this->assertSame([['id' => 1, 'name' => 'first']], $collection->toArray()['data']); - $this->assertSame('/items', $collection->toArray()['path']); - $this->assertSame(15, $collection->toArray()['per_page']); + $this->assertSame('/items', $collection->toArray()['meta']['path']); + $this->assertSame(15, $collection->toArray()['meta']['per_page']); $this->assertSame([1], array_column(iterator_to_array($collection), 'id')); } @@ -167,8 +409,8 @@ public function testNonTransformableDtoItemsRemainRawAcrossCollectionShapes(): v $paginatedOutput = $paginated->toArray(); $this->assertSame($paginatedDto, $paginatedOutput['data'][0]); - $this->assertSame(2, $paginatedOutput['current_page']); - $this->assertSame('/paginated', $paginatedOutput['path']); + $this->assertSame(2, $paginatedOutput['meta']['current_page']); + $this->assertSame('/paginated', $paginatedOutput['meta']['path']); $cursorPaginated = new CursorPaginatedDataCollection( CollectionDto::class, @@ -178,8 +420,8 @@ public function testNonTransformableDtoItemsRemainRawAcrossCollectionShapes(): v $cursorOutput = $cursorPaginated->toArray(); $this->assertSame($cursorDto, $cursorOutput['data'][0]); - $this->assertSame('/cursor', $cursorOutput['path']); - $this->assertSame(15, $cursorOutput['per_page']); + $this->assertSame('/cursor', $cursorOutput['meta']['path']); + $this->assertSame(15, $cursorOutput['meta']['per_page']); } } diff --git a/tests/Data/DataPipes/FillRouteParameterPropertiesDataPipeTest.php b/tests/Data/DataPipes/FillRouteParameterPropertiesDataPipeTest.php new file mode 100644 index 0000000000..b2a40dfc20 --- /dev/null +++ b/tests/Data/DataPipes/FillRouteParameterPropertiesDataPipeTest.php @@ -0,0 +1,156 @@ +bindRouteParameters([ + 'id' => '123', + 'slug' => 'foo-bar', + 'title' => 'Foo Bar', + 'tags' => ['foo', 'bar'], + 'nested' => ['simple' => ['string' => 'baz']], + ]); + + $data = RouteParameterData::from($request); + + $this->assertSame(123, $data->id); + $this->assertSame('foo-bar', $data->slug); + $this->assertSame('Foo Bar', $data->title); + $this->assertSame(['foo', 'bar'], $data->tags); + $this->assertSame('baz', $data->nested->simple->string); + } + + public function testCanFillDataPropertiesFromRouteParameterProperties(): void + { + $request = $this->bindRouteParameters([ + 'foo' => (new RouteParameterModel)->forceFill(['id' => 123]), + 'bar' => ['name' => 'Baz'], + 'baz' => (object) ['description' => 'The bazzest bazz there is'], + ]); + + $data = RouteParameterPropertyData::from($request); + + $this->assertSame(123, $data->id); + $this->assertSame('Baz', $data->name); + $this->assertSame('The bazzest bazz there is', $data->description); + } + + public function testCanFillDataPropertiesFromRouteParametersUsingCustomPropertyMapping(): void + { + $request = $this->bindRouteParameters([ + 'something' => [ + 'name' => 'Something', + 'nested' => [ + 'foo' => 'bar', + ], + 'tags' => ['foo', 'bar'], + 'rows' => [ + ['total' => 10], + ['total' => 20], + ['total' => 30], + ], + ], + 'user_id' => '1', + ]); + + $data = RouteParameterPathData::from($request); + + $this->assertSame('Something', $data->title); + $this->assertSame('bar', $data->foo); + $this->assertSame('foo', $data->tag); + $this->assertSame([10, 20, 30], $data->totals); + $this->assertSame(1, $data->userId); + } +} + +class RouteParameterData extends Data +{ + /** + * Create a data object from route parameters. + */ + public function __construct( + #[RouteParameter('id')] + public int $id, + #[RouteParameter('slug')] + public string $slug, + #[RouteParameter('title')] + public string $title, + #[RouteParameter('tags')] + public array $tags, + #[RouteParameter('nested')] + public NestedData $nested, + ) { + } +} + +class RouteParameterModel extends Model +{ +} + +class RouteParameterPropertyData extends Data +{ + /** + * Create a data object from route parameter properties. + */ + public function __construct( + #[RouteParameter('foo', property: 'id')] + public int $id, + #[RouteParameter('bar', property: 'name')] + public string $name, + #[RouteParameter('baz', property: 'description')] + public string $description, + ) { + } +} + +class RouteParameterPathData extends Data +{ + /** + * Create a data object from paths into route parameters. + */ + public function __construct( + #[RouteParameter('something', property: 'name')] + public string $title, + #[RouteParameter('something', property: 'nested.foo')] + public string $foo, + #[RouteParameter('something', property: 'tags.0')] + public string $tag, + #[RouteParameter('something', property: 'rows.*.total')] + public array $totals, + #[RouteParameter('user_id'), MapInputName('user_id')] + public int $userId, + ) { + } +} diff --git a/tests/Data/DataServiceProviderTest.php b/tests/Data/DataServiceProviderTest.php index cf8742ec36..a097a63659 100644 --- a/tests/Data/DataServiceProviderTest.php +++ b/tests/Data/DataServiceProviderTest.php @@ -17,7 +17,7 @@ class DataServiceProviderTest extends TestCase { - // REMOVED: Structure-cache command tests; worker memory is the metadata cache boundary. + // REMOVED: Structure-cache tests (the cache command and CachedDataConfig); worker memory is the metadata cache boundary. // REMOVED: Livewire/Wireable and TypeScript integration tests; Hypervel has no matching Data integration. protected function getPackageProviders(Application $app): array @@ -62,7 +62,7 @@ public function testProviderWarmsFixedServicesAfterNonTestingApplicationBoot(): $provider = new DataServiceProvider($application); $provider->register(); - $provider->boot(); + $provider->boot($application->make('config')); $this->assertTrue($application->resolved(DataConfig::class)); $this->assertFalse($application->resolved(DataCreator::class)); diff --git a/tests/Data/DataTest.php b/tests/Data/DataTest.php new file mode 100644 index 0000000000..c2f71efca4 --- /dev/null +++ b/tests/Data/DataTest.php @@ -0,0 +1,151 @@ +assertSame(['string' => 'Hi'], $data::from('Hi')->toArray()); + $this->assertEquals(new $data('Hi'), $data::from(['string' => 'Hi'])); + $this->assertEquals(new $data('Hi'), $data::from('Hi')); + } + + public function testCanUseDataAsAnDto(): void + { + $dto = SimpleDto::from('Hello World'); + + $this->assertInstanceOf(Dto::class, $dto); + $this->assertNotInstanceOf(Data::class, $dto); + $this->assertSame('Hello World', $dto->string); + + $methods = get_class_methods($dto); + + foreach (['toArray', 'toJson', 'toResponse', 'all', 'include', 'exclude', 'only', 'except', 'transform', 'with', 'jsonSerialize'] as $method) { + $this->assertNotContains($method, $methods); + } + + $this->assertNotInstanceOf(TransformableDataContract::class, $dto); + $this->assertNotInstanceOf(ResponsableDataContract::class, $dto); + $this->assertNotInstanceOf(Castable::class, $dto); + $this->assertInstanceOf(RequestCastable::class, $dto); + + $this->expectException(ValidationException::class); + + SimpleDto::validate(['string' => null]); + } + + public function testCanUseDataAsAnResource(): void + { + $resource = SimpleResource::from('Hello World'); + + $this->assertInstanceOf(Resource::class, $resource); + $this->assertNotInstanceOf(Data::class, $resource); + $this->assertSame('Hello World', $resource->string); + + $methods = get_class_methods($resource); + + foreach (['toArray', 'toJson', 'toResponse', 'all', 'include', 'exclude', 'only', 'except', 'transform', 'with', 'jsonSerialize'] as $method) { + $this->assertContains($method, $methods); + } + + $this->assertNotContains('validate', $methods); + $this->assertInstanceOf(TransformableDataContract::class, $resource); + $this->assertInstanceOf(ResponsableDataContract::class, $resource); + $this->assertInstanceOf(Castable::class, $resource); + $this->assertInstanceOf(RequestCastable::class, $resource); + } + + public function testDataHasEveryCapability(): void + { + $this->assertTrue(is_a(Data::class, TransformableDataContract::class, true)); + $this->assertTrue(is_a(Data::class, ResponsableDataContract::class, true)); + $this->assertTrue(is_a(Data::class, ValidateableDataContract::class, true)); + $this->assertTrue(is_a(Data::class, Castable::class, true)); + $this->assertTrue(is_a(Data::class, RequestCastable::class, true)); + } + + public function testPaginatedCollectionsAreTransformableButNotStoredByEloquent(): void + { + $this->assertTrue(is_a(DataCollection::class, TransformableDataContract::class, true)); + $this->assertTrue(is_a(DataCollection::class, Castable::class, true)); + $this->assertFalse(is_a(DataCollection::class, RequestCastable::class, true)); + $this->assertTrue(is_a(DataCollection::class, ResponsableDataContract::class, true)); + $this->assertTrue(is_a(PaginatedDataCollection::class, TransformableDataContract::class, true)); + $this->assertFalse(is_a(PaginatedDataCollection::class, Castable::class, true)); + $this->assertFalse(is_a(PaginatedDataCollection::class, RequestCastable::class, true)); + $this->assertTrue(is_a(PaginatedDataCollection::class, ResponsableDataContract::class, true)); + $this->assertTrue(is_a(CursorPaginatedDataCollection::class, TransformableDataContract::class, true)); + $this->assertFalse(is_a(CursorPaginatedDataCollection::class, Castable::class, true)); + $this->assertFalse(is_a(CursorPaginatedDataCollection::class, RequestCastable::class, true)); + $this->assertTrue(is_a(CursorPaginatedDataCollection::class, ResponsableDataContract::class, true)); + } +} diff --git a/tests/Data/Eloquent/DataCollectionEloquentCastTest.php b/tests/Data/Eloquent/DataCollectionEloquentCastTest.php index eedc5a847e..94a983ec49 100644 --- a/tests/Data/Eloquent/DataCollectionEloquentCastTest.php +++ b/tests/Data/Eloquent/DataCollectionEloquentCastTest.php @@ -8,8 +8,6 @@ use Hypervel\Data\Attributes\Computed; use Hypervel\Data\Attributes\Hidden; use Hypervel\Data\Attributes\MapOutputName; -use Hypervel\Data\Attributes\PropertyForMorph; -use Hypervel\Data\Contracts\PropertyMorphableData; use Hypervel\Data\Data; use Hypervel\Data\DataCollection; use Hypervel\Data\DataServiceProvider; @@ -23,14 +21,38 @@ use Hypervel\Database\Eloquent\Casts\Json; use Hypervel\Database\Eloquent\JsonEncodingException; use Hypervel\Database\Eloquent\Model; +use Hypervel\Foundation\Testing\RefreshDatabase; +use Hypervel\Support\Collection; use Hypervel\Support\Facades\Crypt; +use Hypervel\Support\Facades\DB; +use Hypervel\Support\Fluent; use Hypervel\Testbench\TestCase; +use Hypervel\Tests\Data\Fixtures\AbstractData\AbstractData; +use Hypervel\Tests\Data\Fixtures\AbstractData\AbstractDataA; +use Hypervel\Tests\Data\Fixtures\AbstractData\AbstractDataB; +use Hypervel\Tests\Data\Fixtures\AbstractPropertyMorphableData; +use Hypervel\Tests\Data\Fixtures\Enums\DummyBackedEnum; +use Hypervel\Tests\Data\Fixtures\LazyData; +use Hypervel\Tests\Data\Fixtures\Models\DummyModelWithCasts; +use Hypervel\Tests\Data\Fixtures\Models\DummyModelWithCustomCollectionCasts; +use Hypervel\Tests\Data\Fixtures\Models\DummyModelWithDefaultCasts; +use Hypervel\Tests\Data\Fixtures\Models\DummyModelWithEncryptedCasts; +use Hypervel\Tests\Data\Fixtures\Models\DummyModelWithJson; +use Hypervel\Tests\Data\Fixtures\MultiData; +use Hypervel\Tests\Data\Fixtures\PropertyMorphableDataA; +use Hypervel\Tests\Data\Fixtures\PropertyMorphableDataB; +use Hypervel\Tests\Data\Fixtures\SimpleData; +use Hypervel\Tests\Data\Fixtures\SimpleDataCollection; use JsonException; use RuntimeException; use stdClass; class DataCollectionEloquentCastTest extends TestCase { + use RefreshDatabase; + + protected bool $migrateRefresh = true; + /** * Get package providers for the data collection cast test application. */ @@ -52,7 +74,318 @@ protected function defineEnvironment(Application $app): void $config->set('app.previous_keys', []); } - public function testCollectionCastRoundTripsCollectionsArraysKeysAndNull(): void + /** + * Get the migration options. + */ + protected function migrateFreshUsing(): array + { + return [ + '--database' => $this->getRefreshConnection(), + '--realpath' => true, + '--path' => __DIR__ . '/../Fixtures/Migrations', + ]; + } + + public function testCanSaveADataCollection(): void + { + DummyModelWithCasts::create([ + 'data_collection' => SimpleData::collect([ + 'Hello', + 'World', + ], DataCollection::class), + ]); + + $this->assertDatabaseHas(DummyModelWithCasts::class, [ + 'data_collection' => json_encode([ + ['string' => 'Hello'], + ['string' => 'World'], + ]), + ]); + } + + public function testCanSaveADataObjectAsAnArray(): void + { + DummyModelWithCasts::create([ + 'data_collection' => [ + ['string' => 'Hello'], + ['string' => 'World'], + ], + ]); + + $this->assertDatabaseHas(DummyModelWithCasts::class, [ + 'data_collection' => json_encode([ + ['string' => 'Hello'], + ['string' => 'World'], + ]), + ]); + } + + public function testCanSaveADataObjectAsAnArrayFromACollection(): void + { + DummyModelWithCasts::create([ + 'data_collection' => new Collection([ + ['string' => 'Hello'], + ['string' => 'World'], + ]), + ]); + + $this->assertDatabaseHas(DummyModelWithCasts::class, [ + 'data_collection' => json_encode([ + ['string' => 'Hello'], + ['string' => 'World'], + ]), + ]); + } + + public function testCanLoadADataObject(): void + { + DB::table('dummy_model_with_casts')->insert([ + 'data_collection' => json_encode([ + ['string' => 'Hello'], + ['string' => 'World'], + ]), + ]); + + $this->assertEquals(new DataCollection(SimpleData::class, [ + new SimpleData('Hello'), + new SimpleData('World'), + ]), DummyModelWithCasts::first()->data_collection); + } + + public function testCanSaveANullAsAValue(): void + { + DummyModelWithCasts::create([ + 'data_collection' => null, + ]); + + $this->assertDatabaseHas(DummyModelWithCasts::class, [ + 'data_collection' => null, + ]); + } + + public function testCanLoadNullAsAValue(): void + { + DB::table('dummy_model_with_casts')->insert([ + 'data_collection' => null, + ]); + + $this->assertNull(DummyModelWithCasts::first()->data_collection); + } + + public function testCanSaveACustomDataCollection(): void + { + DummyModelWithCustomCollectionCasts::create([ + 'data_collection' => [ + ['string' => 'Hello'], + ['string' => 'World'], + ], + ]); + + // Eloquent's JSON codec encodes the stored value, so the collection's pretty-printing toJson() doesn't apply + // (README). + $this->assertDatabaseHas(DummyModelWithCustomCollectionCasts::class, [ + 'data_collection' => json_encode([ + ['string' => 'Hello'], + ['string' => 'World'], + ]), + ]); + } + + public function testRetrievesCustomDataCollection(): void + { + DB::table('dummy_model_with_casts')->insert([ + 'data_collection' => json_encode([ + ['string' => 'Hello'], + ['string' => 'World'], + ]), + ]); + + $this->assertEquals(new SimpleDataCollection( + SimpleData::class, + [ + new SimpleData('Hello'), + new SimpleData('World'), + ] + ), DummyModelWithCustomCollectionCasts::first()->data_collection); + } + + public function testLoadsACustomDataCollectionWhenNullableArgumentUsedAndValueIsNullInDatabase(): void + { + DB::table('dummy_model_with_casts')->insert([ + 'data' => null, + ]); + + $collection = DummyModelWithDefaultCasts::first()->data_collection; + + $this->assertInstanceOf(SimpleDataCollection::class, $collection); + $this->assertCount(0, $collection); + } + + public function testCanUseAnAbstractDataCollectionWithMultipleChildren(): void + { + $abstractA = new AbstractDataA('A\A'); + $abstractB = new AbstractDataB('B\B'); + + $modelId = DummyModelWithCasts::create([ + 'abstract_collection' => [$abstractA, $abstractB], + ])->id; + + $model = DummyModelWithCasts::find($modelId); + + $this->assertInstanceOf(DataCollection::class, $model->abstract_collection); + $this->assertContainsOnlyInstancesOf(AbstractData::class, $model->abstract_collection->items()); + $this->assertInstanceOf(AbstractDataA::class, $model->abstract_collection[0]); + $this->assertInstanceOf(AbstractDataB::class, $model->abstract_collection[1]); + } + + public function testCanLoadAndSaveAnAbstractPropertyMorphableDataCollection(): void + { + $modelClass = new class extends Model { + protected array $guarded = []; + + protected array $casts = [ + 'data_collection' => SimpleDataCollection::class . ':' . AbstractPropertyMorphableData::class, + ]; + + protected ?string $table = 'dummy_model_with_casts'; + + public bool $timestamps = false; + }; + + $abstractA = new PropertyMorphableDataA('foo', DummyBackedEnum::FOO); + $abstractB = new PropertyMorphableDataB('bar'); + + $modelId = $modelClass::create([ + 'data_collection' => [$abstractA, $abstractB], + ])->id; + + // Eloquent's JSON codec encodes the stored value, rather than the collection's pretty-printing toJson(). + $this->assertDatabaseHas($modelClass::class, [ + 'data_collection' => json_encode([ + ['a' => 'foo', 'enum' => 'foo', 'variant' => 'a'], + ['b' => 'bar', 'variant' => 'b'], + ]), + ]); + + $model = $modelClass::find($modelId); + + $this->assertInstanceOf(PropertyMorphableDataA::class, $model->data_collection[0]); + $this->assertSame('foo', $model->data_collection[0]->a); + $this->assertSame(DummyBackedEnum::FOO, $model->data_collection[0]->enum); + $this->assertInstanceOf(PropertyMorphableDataB::class, $model->data_collection[1]); + $this->assertSame('bar', $model->data_collection[1]->b); + } + + public function testCanSaveADataCollectionWithLazyPropertiesWhichGetResolved(): void + { + DummyModelWithCasts::create([ + 'lazy_data_collection' => LazyData::collect([ + LazyData::fromString('Hello'), + LazyData::fromString('World'), + ], DataCollection::class), + ]); + + $this->assertDatabaseHas(DummyModelWithCasts::class, [ + 'lazy_data_collection' => json_encode([ + ['name' => 'Hello'], + ['name' => 'World'], + ]), + ]); + } + + public function testCanCorrectlyDetectIfTheAttributeIsDirty(): void + { + // Set a raw JSON string with spaces in it to mimic database behavior + $model = new DummyModelWithJson; + $model->setRawAttributes(['data_collection' => '[{"second": "Second", "first": "First"}, {"first": "Third", "second": "Fourth"}]']); + $model->save(); + + $model->data_collection = [ + new MultiData('First', 'Second'), + new MultiData('Third', 'Fourth'), + ]; + + $this->assertSame('[{"second": "Second", "first": "First"}, {"first": "Third", "second": "Fourth"}]', $model->getRawOriginal('data_collection')); + $this->assertSame('[{"first":"First","second":"Second"},{"first":"Third","second":"Fourth"}]', $model->getAttributes()['data_collection']); + $this->assertFalse($model->isDirty('data_collection')); + } + + public function testFlagsTheAttributeAsDirtyWhenItIsEncryptedAndThereArePreviousEncryptionKeys(): void + { + // The encrypter reads app.previous_keys when it is created, so the key is added to it directly. + try { + Crypt::previousKeys([random_bytes(32)]); + + $model = new DummyModelWithEncryptedCasts; + $model->data_collection = [ + new SimpleData('First'), + new SimpleData('Second'), + ]; + $model->save(); + + $model->data_collection = $model->data_collection; + + $this->assertNotSame($model->getAttributes()['data_collection'], $model->getRawOriginal('data_collection')); + $this->assertTrue($model->isDirty('data_collection')); + } finally { + Crypt::previousKeys([]); + } + } + + public function testDoesNotFlagTheAttributeAsDirtyWhenItIsEncryptedAndThereAreNoPreviousEncryptionKeys(): void + { + $model = new DummyModelWithEncryptedCasts; + $model->data_collection = [ + new SimpleData('First'), + new SimpleData('Second'), + ]; + $model->save(); + + $model->data_collection = $model->data_collection; + + $this->assertNotSame($model->getAttributes()['data_collection'], $model->getRawOriginal('data_collection')); + $this->assertFalse($model->isDirty('data_collection')); + } + + public function testCanUpdateAModelWhereTheCastIsInitiallyNull(): void + { + $model = new DummyModelWithCasts; + $model->setRawAttributes(['data_collection' => null]); + $model->save(); + + $this->assertDatabaseHas(DummyModelWithCasts::class, [ + 'data_collection' => null, + ]); + + $model->update([ + 'data_collection' => Collection::make([new SimpleData('Test')]), + ]); + + $this->assertDatabaseHas(DummyModelWithCasts::class, [ + 'data_collection' => json_encode([['string' => 'Test']]), + ]); + } + + public function testCanUpdateAModelWhereTheCastIsInitiallyNotNull(): void + { + $model = DummyModelWithCasts::create([ + 'data_collection' => Collection::make([new SimpleData('Test')]), + ]); + + $this->assertDatabaseHas(DummyModelWithCasts::class, [ + 'data_collection' => json_encode([['string' => 'Test']]), + ]); + + $model->update([ + 'data_collection' => null, + ]); + + $this->assertDatabaseHas(DummyModelWithCasts::class, [ + 'data_collection' => null, + ]); + } + + public function testCollectionCastKeepsItemKeys(): void { $model = new CollectionCastModel; $model->items = new DataCollection(CollectionItemData::class, [ @@ -65,41 +398,40 @@ public function testCollectionCastRoundTripsCollectionsArraysKeysAndNull(): void 'second' => ['name' => 'Abigail'], ], Json::decode($model->getAttributes()['items'])); - $model = new CollectionCastModel; - $model->items = [ - ['name' => 'Taylor'], - ['name' => 'Abigail'], - ]; - - $this->assertSame([ - ['name' => 'Taylor'], - ['name' => 'Abigail'], - ], Json::decode($model->getAttributes()['items'])); - $model = new CollectionCastModel; $model->setRawAttributes([ 'items' => '{"first":{"name":"Taylor"},"second":{"name":"Abigail"}}', ]); - $this->assertInstanceOf(DataCollection::class, $model->items); $this->assertSame(['first', 'second'], array_keys($model->items->items())); $this->assertEquals(new CollectionItemData('Taylor'), $model->items['first']); $this->assertEquals(new CollectionItemData('Abigail'), $model->items['second']); - - $model = new CollectionCastModel; - $model->items = null; - - $this->assertNull($model->getAttributes()['items']); - $this->assertNull($model->items); } - public function testCollectionCastReturnsTheDeclaredCollectionSubclass(): void + public function testCollectionCastAcceptsCollectionsAndOtherArrayables(): void { $model = new CollectionCastModel; - $model->setRawAttributes(['custom_items' => '[{"name":"Taylor"}]']); + $model->graph_items = Collection::make([ + new CollectionGraphItemData( + name: 'Taylor', + secret: 'private', + lazy: Lazy::create(static fn (): string => 'resolved'), + ), + ]); - $this->assertInstanceOf(CustomDataCollection::class, $model->custom_items); - $this->assertEquals(new CollectionItemData('Taylor'), $model->custom_items[0]); + $this->assertSame([ + ['name' => 'Taylor', 'secret' => 'private', 'lazy' => 'resolved'], + ], Json::decode($model->getAttributes()['graph_items'])); + + $model->items = new Fluent([ + 'first' => ['name' => 'Taylor'], + 'second' => new CollectionItemData('Abigail'), + ]); + + $this->assertSame([ + 'first' => ['name' => 'Taylor'], + 'second' => ['name' => 'Abigail'], + ], Json::decode($model->getAttributes()['items'])); } public function testStoredCollectionsUseOneInternalRootItemOperation(): void @@ -238,115 +570,71 @@ public function testCollectionCastRejectsAnEncoderFalseResult(): void } } - public function testPropertyMorphableCollectionUsesOrdinaryItemPayloads(): void - { - $caster = new DataCollectionEloquentCast(CollectionPropertyMorphData::class); - $model = new CollectionCastModel; - $encoded = $caster->set($model, 'property_morph_items', [ - new CollectionPropertyMorphFoo('first'), - new CollectionPropertyMorphBar('second'), - ], []); - - $this->assertEquals([ - ['name' => 'first', 'variant' => 'foo'], - ['name' => 'second', 'variant' => 'bar'], - ], Json::decode($encoded)); - - $decoded = $caster->get($model, 'property_morph_items', $encoded, []); - - $this->assertInstanceOf(CollectionPropertyMorphFoo::class, $decoded[0]); - $this->assertInstanceOf(CollectionPropertyMorphBar::class, $decoded[1]); - } - - public function testAbstractCollectionRoundTripsEnforcedAliasesAndEncryption(): void + public function testAbstractCollectionStoresAliasesOrClassNamesAndRoundTripsEncryption(): void { $this->app->make(DataConfig::class)->enforceMorphMap([ 'first' => CollectionAbstractFirst::class, - 'second' => CollectionAbstractSecond::class, ]); $items = [ new CollectionAbstractFirst('one'), new CollectionAbstractSecond('two'), ]; - $model = new CollectionCastModel; - $model->abstract_items = $items; - - $this->assertSame([ - ['type' => 'first', 'data' => ['name' => 'one']], - ['type' => 'second', 'data' => ['name' => 'two']], - ], Json::decode($model->getAttributes()['abstract_items'])); - - $model = new CollectionCastModel; - $model->setRawAttributes(['abstract_items' => Json::encode([ + $stored = [ ['type' => 'first', 'data' => ['name' => 'one']], - ['type' => 'second', 'data' => ['name' => 'two']], - ])]); - - $this->assertInstanceOf(CollectionAbstractFirst::class, $model->abstract_items[0]); - $this->assertInstanceOf(CollectionAbstractSecond::class, $model->abstract_items[1]); - + ['type' => CollectionAbstractSecond::class, 'data' => ['name' => 'two']], + ]; $model = new CollectionCastModel; - $model->encrypted_items = [new CollectionItemData('concrete')]; + $model->abstract_items = $items; $model->encrypted_abstract_items = $items; - $encryptedConcrete = $model->getAttributes()['encrypted_items']; - $encrypted = $model->getAttributes()['encrypted_abstract_items']; - $this->assertSame( - [['name' => 'concrete']], - Json::decode(Crypt::decryptString($encryptedConcrete)), - ); - $this->assertNotSame('[', $encrypted[0]); - $this->assertCount(2, Json::decode(Crypt::decryptString($encrypted))); + $this->assertSame($stored, Json::decode($model->getAttributes()['abstract_items'])); + $this->assertSame($stored, Json::decode(Crypt::decryptString($model->getAttributes()['encrypted_abstract_items']))); $model = new CollectionCastModel; $model->setRawAttributes([ - 'encrypted_items' => $encryptedConcrete, - 'encrypted_abstract_items' => $encrypted, + 'abstract_items' => Json::encode($stored), + 'encrypted_abstract_items' => Crypt::encryptString(Json::encode($stored)), ]); - $this->assertEquals(new CollectionItemData('concrete'), $model->encrypted_items[0]); - $this->assertInstanceOf(CollectionAbstractFirst::class, $model->encrypted_abstract_items[0]); - $this->assertInstanceOf(CollectionAbstractSecond::class, $model->encrypted_abstract_items[1]); + foreach (['abstract_items', 'encrypted_abstract_items'] as $attribute) { + $this->assertEquals(new CollectionAbstractFirst('one'), $model->{$attribute}[0]); + $this->assertEquals(new CollectionAbstractSecond('two'), $model->{$attribute}[1]); + } } - public function testAbstractCollectionRejectsMissingAndUnknownAliases(): void + public function testAbstractCollectionRejectsStoredTypesOutsideTheDeclaredClass(): void { + $this->app->make(DataConfig::class)->enforceMorphMap([ + 'other' => CollectionOtherData::class, + ]); + CollectionUnrelatedFactory::$created = false; + $caster = new DataCollectionEloquentCast(CollectionAbstractData::class); $model = new CollectionCastModel; - $this->assertThrows( - fn () => $caster->set( - $model, - 'abstract_items', - [new CollectionAbstractFirst('value')], - [], - ), - CannotCastData::class, - 'should have an enforced morph alias', - ); - $this->assertThrows( - fn () => $caster->get( - $model, - 'abstract_items', - '[{"type":"missing","data":{"name":"value"}}]', - [], - ), - CannotCastData::class, - 'is not registered', - ); - $this->assertThrows( - fn () => $caster->get( - $model, - 'abstract_items', - json_encode([ - ['type' => CollectionAbstractFirst::class, 'data' => ['name' => 'value']], - ], JSON_THROW_ON_ERROR), - [], - ), - CannotCastData::class, - 'is not registered', - ); + foreach ([ + 'missing', + 'other', + CollectionOtherData::class, + CollectionAbstractData::class, + CollectionDto::class, + CollectionUnrelatedFactory::class, + ] as $type) { + $this->assertThrows( + fn (): ?DataCollection => $caster->get( + $model, + 'abstract_items', + json_encode([['type' => $type, 'data' => ['name' => 'value']]], JSON_THROW_ON_ERROR), + [], + ), + CannotCastData::class, + 'should be a registered alias or a concrete transformable subtype', + ); + } + + // A stored class outside the declared type is rejected before anything is created from it. + $this->assertFalse(CollectionUnrelatedFactory::$created); } public function testCollectionCastRejectsInvalidAssignedAndStoredItems(): void @@ -408,25 +696,6 @@ public function testCollectionDirtyComparisonUsesSharedPayloadSemantics(): void $this->assertTrue($model->isDirty('items')); } - - public function testEncryptedCollectionDirtyComparisonHonorsPreviousKeys(): void - { - $first = Crypt::encryptString('[{"name":"Taylor"}]'); - $second = Crypt::encryptString('[{"name":"Taylor"}]'); - $model = new CollectionCastModel; - $model->setRawAttributes(['encrypted_items' => $first], true); - $model->setRawAttributes(['encrypted_items' => $second]); - - $this->assertFalse($model->isDirty('encrypted_items')); - - try { - Crypt::previousKeys([random_bytes(32)]); - - $this->assertTrue($model->isDirty('encrypted_items')); - } finally { - Crypt::previousKeys([]); - } - } } class CollectionCastModel extends Model @@ -439,13 +708,10 @@ protected function casts(): array return [ 'items' => DataCollection::class . ':' . CollectionItemData::class, 'default_items' => DataCollection::class . ':' . CollectionItemData::class . ',default', - 'custom_items' => CustomDataCollection::class . ':' . CollectionItemData::class, 'graph_items' => DataCollection::class . ':' . CollectionGraphItemData::class, 'override_items' => DataCollection::class . ':' . CollectionOverrideData::class, 'abstract_items' => DataCollection::class . ':' . CollectionAbstractData::class, - 'encrypted_items' => DataCollection::class . ':' . CollectionItemData::class . ',encrypted', 'encrypted_abstract_items' => DataCollection::class . ':' . CollectionAbstractData::class . ',encrypted', - 'property_morph_items' => DataCollection::class . ':' . CollectionPropertyMorphData::class, ]; } } @@ -530,13 +796,6 @@ public function with(): array } } -/** - * @extends DataCollection - */ -class CustomDataCollection extends DataCollection -{ -} - abstract class CollectionAbstractData extends Data { public function __construct(public string $name) @@ -566,36 +825,17 @@ public function __construct(public string $name) } } -abstract class CollectionPropertyMorphData extends Data implements PropertyMorphableData +class CollectionUnrelatedFactory { - public function __construct( - #[PropertyForMorph] - public string $variant, - ) { - } + public static bool $created = false; - public static function morph(array $properties): ?string - { - return match ($properties['variant'] ?? null) { - 'foo' => CollectionPropertyMorphFoo::class, - 'bar' => CollectionPropertyMorphBar::class, - default => null, - }; - } -} - -class CollectionPropertyMorphFoo extends CollectionPropertyMorphData -{ - public function __construct(public string $name) + /** + * Record that a value was created. + */ + public static function from(mixed ...$payloads): self { - parent::__construct('foo'); - } -} + self::$created = true; -class CollectionPropertyMorphBar extends CollectionPropertyMorphData -{ - public function __construct(public string $name) - { - parent::__construct('bar'); + return new self; } } diff --git a/tests/Data/Eloquent/DataEloquentCastTest.php b/tests/Data/Eloquent/DataEloquentCastTest.php index fb0d8aa2a0..3c3a20eff8 100644 --- a/tests/Data/Eloquent/DataEloquentCastTest.php +++ b/tests/Data/Eloquent/DataEloquentCastTest.php @@ -4,12 +4,14 @@ namespace Hypervel\Tests\Data\Eloquent\DataEloquentCastTest; +use Hypervel\Contracts\Encryption\DecryptException; use Hypervel\Contracts\Foundation\Application; use Hypervel\Data\Attributes\Computed; use Hypervel\Data\Attributes\DataCollectionOf; use Hypervel\Data\Attributes\Hidden; use Hypervel\Data\Attributes\MapOutputName; use Hypervel\Data\Attributes\PropertyForMorph; +use Hypervel\Data\Contracts\BaseData; use Hypervel\Data\Contracts\PropertyMorphableData; use Hypervel\Data\Data; use Hypervel\Data\DataCollection; @@ -24,13 +26,29 @@ use Hypervel\Database\Eloquent\Casts\Json; use Hypervel\Database\Eloquent\JsonEncodingException; use Hypervel\Database\Eloquent\Model; +use Hypervel\Foundation\Testing\RefreshDatabase; use Hypervel\Support\Facades\Crypt; +use Hypervel\Support\Facades\DB; use Hypervel\Testbench\TestCase; +use Hypervel\Tests\Data\Fixtures\AbstractData\AbstractDataA; +use Hypervel\Tests\Data\Fixtures\AbstractData\AbstractDataB; +use Hypervel\Tests\Data\Fixtures\Enums\DummyBackedEnum; +use Hypervel\Tests\Data\Fixtures\LazyData; +use Hypervel\Tests\Data\Fixtures\Models\DummyModelWithCasts; +use Hypervel\Tests\Data\Fixtures\Models\DummyModelWithDefaultCasts; +use Hypervel\Tests\Data\Fixtures\Models\DummyModelWithEncryptedCasts; +use Hypervel\Tests\Data\Fixtures\Models\DummyModelWithJson; +use Hypervel\Tests\Data\Fixtures\SimpleData; +use Hypervel\Tests\Data\Fixtures\SimpleDataWithDefaultValue; use JsonException; use stdClass; class DataEloquentCastTest extends TestCase { + use RefreshDatabase; + + protected bool $migrateRefresh = true; + /** * Get package providers for the data cast test application. */ @@ -52,39 +70,323 @@ protected function defineEnvironment(Application $app): void $config->set('app.previous_keys', []); } - public function testDataCastRoundTripsObjectsArraysNullAndDefaults(): void + /** + * Get the migration options. + */ + protected function migrateFreshUsing(): array { - $model = new DataCastModel; - $model->data = new StoredSimpleData('Taylor'); + return [ + '--database' => $this->getRefreshConnection(), + '--realpath' => true, + '--path' => __DIR__ . '/../Fixtures/Migrations', + ]; + } - $this->assertSame(['name' => 'Taylor'], Json::decode($model->getAttributes()['data'])); + public function testCanSaveADataObject(): void + { + DummyModelWithCasts::create([ + 'data' => new SimpleData('Test'), + ]); - $model = new DataCastModel; - $model->data = ['name' => 'Abigail']; + $this->assertDatabaseHas(DummyModelWithCasts::class, [ + 'data' => json_encode(['string' => 'Test']), + ]); + } - $this->assertSame(['name' => 'Abigail'], Json::decode($model->getAttributes()['data'])); + public function testCanSaveADataObjectAsAnArray(): void + { + DummyModelWithCasts::create([ + 'data' => ['string' => 'Test'], + ]); - $model = new DataCastModel; - $model->setRawAttributes(['data' => '{"name":"Dayle"}']); + $this->assertDatabaseHas(DummyModelWithCasts::class, [ + 'data' => json_encode(['string' => 'Test']), + ]); + } - $this->assertEquals(new StoredSimpleData('Dayle'), $model->data); + public function testCanLoadADataObject(): void + { + DB::table('dummy_model_with_casts')->insert([ + 'data' => json_encode(['string' => 'Test']), + ]); - $model = new DataCastModel; - $model->data = null; + $this->assertEquals(new SimpleData('Test'), DummyModelWithCasts::first()->data); + } - $this->assertNull($model->getAttributes()['data']); - $this->assertNull($model->data); + public function testCanSaveANullAsAValue(): void + { + DummyModelWithCasts::create([ + 'data' => null, + ]); - $model = new DataCastModel; - $model->setRawAttributes([ - 'default_data' => null, - 'empty_default_data' => null, + $this->assertDatabaseHas(DummyModelWithCasts::class, [ + 'data' => null, + ]); + } + + public function testCanLoadNullAsAValue(): void + { + DB::table('dummy_model_with_casts')->insert([ + 'data' => null, + ]); + + $this->assertNull(DummyModelWithCasts::first()->data); + } + + public function testLoadsACastObjectWhenNullableArgumentUsedAndValueIsNullInDatabase(): void + { + DB::table('dummy_model_with_casts')->insert([ + 'data' => null, + ]); + + $data = DummyModelWithDefaultCasts::first()->data; + + $this->assertInstanceOf(SimpleDataWithDefaultValue::class, $data); + $this->assertSame('default', $data->string); + } + + public function testCanUseAnAbstractDataClassWithMultipleChildren(): void + { + $abstractA = new AbstractDataA('A\A'); + $abstractB = new AbstractDataB('B\B'); + + $modelId = DummyModelWithCasts::create([ + 'abstract_data' => $abstractA, + ])->id; + + $model = DummyModelWithCasts::find($modelId); + + $this->assertInstanceOf(AbstractDataA::class, $model->abstract_data); + $this->assertSame('A\A', $model->abstract_data->a); + + $model->abstract_data = $abstractB; + $model->save(); + + $model = DummyModelWithCasts::find($modelId); + + $this->assertInstanceOf(AbstractDataB::class, $model->abstract_data); + $this->assertSame('B\B', $model->abstract_data->b); + } + + public function testCanUseAnAbstractDataClassWithMorphMap(): void + { + $this->app->make(DataConfig::class)->enforceMorphMap([ + 'a' => AbstractDataA::class, + ]); + + $abstractA = new AbstractDataA('A\A'); + $abstractB = new AbstractDataB('B\B'); + + $modelA = DummyModelWithCasts::create([ + 'abstract_data' => $abstractA, + ]); + + $modelB = DummyModelWithCasts::create([ + 'abstract_data' => $abstractB, + ]); + + $this->assertSame(['type' => 'a', 'data' => ['a' => 'A\A']], Json::decode($modelA->getRawOriginal('abstract_data'))); + $this->assertSame( + ['type' => AbstractDataB::class, 'data' => ['b' => 'B\B']], + Json::decode($modelB->getRawOriginal('abstract_data')), + ); + + $loadedMorphedModel = DummyModelWithCasts::find($modelA->id); + + $this->assertInstanceOf(AbstractDataA::class, $loadedMorphedModel->abstract_data); + $this->assertSame('A\A', $loadedMorphedModel->abstract_data->a); + } + + public function testCanSaveAnEncryptedDataObject(): void + { + $model = DummyModelWithEncryptedCasts::create([ + 'data' => new SimpleData('Test'), + ]); + + try { + $decrypted = Crypt::decryptString($model->getRawOriginal('data')); + } catch (DecryptException) { + $this->fail('Expected the stored value to be encrypted.'); + } + + $this->assertSame(['string' => 'Test'], Json::decode($decrypted)); + } + + public function testCanLoadAnEncryptedDataObject(): void + { + DummyModelWithEncryptedCasts::create([ + 'data' => new SimpleData('Test'), + ]); + + $this->assertEquals(new SimpleData('Test'), DummyModelWithEncryptedCasts::first()->data); + } + + public function testCanLoadAndSaveAnAbstractDefinedDataObject(): void + { + $abstractA = new AbstractDataA('A\A'); + + $modelId = DummyModelWithEncryptedCasts::create([ + 'abstract_data' => $abstractA, + ])->id; + + $model = DummyModelWithEncryptedCasts::find($modelId); + + $this->assertInstanceOf(AbstractDataA::class, $model->abstract_data); + $this->assertSame('A\A', $model->abstract_data->a); + + try { + $decrypted = Crypt::decryptString($model->getRawOriginal('abstract_data')); + } catch (DecryptException) { + $this->fail('Expected the stored value to be encrypted.'); + } + + $this->assertSame(['type' => AbstractDataA::class, 'data' => ['a' => 'A\A']], Json::decode($decrypted)); + } + + public function testCanLoadAndSaveAnAbstractPropertyMorphableDataObject(): void + { + $modelClass = new class extends Model { + protected array $guarded = []; + + protected array $casts = [ + 'data' => TestCastAbstractPropertyMorphableData::class, + ]; + + protected ?string $table = 'dummy_model_with_casts'; + + public bool $timestamps = false; + }; + + $abstractA = new TestCastPropertyMorphableDataFoo('foo'); + + $modelId = $modelClass::create([ + 'data' => $abstractA, + ])->id; + + $this->assertDatabaseHas($modelClass::class, [ + 'data' => json_encode(['a' => 'foo', 'variant' => 'foo']), + ]); + + $model = $modelClass::find($modelId); + + $this->assertInstanceOf(TestCastPropertyMorphableDataFoo::class, $model->data); + $this->assertSame('foo', $model->data->a); + $this->assertSame(DummyBackedEnum::FOO, $model->data->variant); + } + + public function testCanCorrectlyDetectIfTheAttributeIsDirty(): void + { + $model = new DummyModelWithJson; + // Set raw because we want to inverse the order of the keys + $model->setRawAttributes(['data' => json_encode(['second' => 'Second', 'first' => 'First'])]); + $model->save(); + + $model->setRawAttributes(['data' => json_encode(['first' => 'First', 'second' => 'Second'])]); + + $this->assertSame('{"second":"Second","first":"First"}', $model->getRawOriginal('data')); + $this->assertSame('{"first":"First","second":"Second"}', $model->getAttributes()['data']); + $this->assertFalse($model->isDirty('data')); + + $model->data->first = 'First2'; + + $this->assertTrue($model->isDirty('data')); + } + + public function testCanCorrectlyDetectIfTheAttributeIsDirtyWithNullValues(): void + { + $model = new DummyModelWithJson; + $model->save(); + + $model->setRawAttributes(['data' => json_encode(['first' => 'First', 'second' => 'Second'])]); + + $this->assertNull($model->getRawOriginal('data')); + $this->assertSame('{"first":"First","second":"Second"}', $model->getAttributes()['data']); + $this->assertTrue($model->isDirty('data')); + } + + public function testFlagsTheAttributeAsDirtyWhenItIsEncryptedAndThereArePreviousEncryptionKeys(): void + { + // The encrypter reads app.previous_keys when it is created, so the key is added to it directly. + try { + Crypt::previousKeys([random_bytes(32)]); + + $model = new DummyModelWithEncryptedCasts; + $model->data = new SimpleData('First'); + $model->save(); + + $model->data = $model->data; + + $this->assertNotSame($model->getAttributes()['data'], $model->getRawOriginal('data')); + $this->assertTrue($model->isDirty('data')); + } finally { + Crypt::previousKeys([]); + } + } + + public function testDoesNotFlagTheAttributeAsDirtyWhenItIsEncryptedAndThereAreNoPreviousEncryptionKeys(): void + { + $model = new DummyModelWithEncryptedCasts; + $model->data = new SimpleData('First'); + $model->save(); + + $model->data = $model->data; + + $this->assertNotSame($model->getAttributes()['data'], $model->getRawOriginal('data')); + $this->assertFalse($model->isDirty('data')); + } + + public function testCanUpdateAModelWhereTheCastIsInitiallyNull(): void + { + $model = DummyModelWithCasts::create([ + 'data' => null, + ]); + + $this->assertDatabaseHas(DummyModelWithCasts::class, [ + 'data' => null, ]); - $this->assertEquals(new StoredDefaultData, $model->default_data); - $this->assertEquals(new StoredEmptyData, $model->empty_default_data); + $model->update([ + 'data' => new SimpleData('Test'), + ]); + + $this->assertDatabaseHas(DummyModelWithCasts::class, [ + 'data' => json_encode(['string' => 'Test']), + ]); + } - foreach (['{}', '[]'] as $stored) { + public function testCanSaveADataObjectWithLazyPropertiesWhichGetResolved(): void + { + DummyModelWithCasts::create([ + 'lazy_data' => LazyData::fromString('Test'), + ]); + + $this->assertDatabaseHas(DummyModelWithCasts::class, [ + 'lazy_data' => json_encode(['name' => 'Test']), + ]); + } + + public function testCanUpdateAModelWhereTheCastIsInitiallyNotNull(): void + { + $model = DummyModelWithCasts::create([ + 'data' => new SimpleData('Test'), + ]); + + $this->assertDatabaseHas(DummyModelWithCasts::class, [ + 'data' => json_encode(['string' => 'Test']), + ]); + + $model->update([ + 'data' => null, + ]); + + $this->assertDatabaseHas(DummyModelWithCasts::class, [ + 'data' => null, + ]); + } + + public function testDefaultDataReadsEmptyStoredObjectsAndLists(): void + { + foreach ([null, '{}', '[]'] as $stored) { $model = new DataCastModel; $model->setRawAttributes(['empty_default_data' => $stored]); @@ -182,118 +484,49 @@ public function testDataCastRejectsAnEncoderFalseResult(): void } } - public function testPropertyMorphableAbstractDataUsesItsOrdinaryPayload(): void - { - $caster = new DataEloquentCast(StoredPropertyMorphData::class); - $model = new DataCastModel; - $encoded = $caster->set($model, 'property_morph_data', new StoredPropertyMorphFoo('value'), []); - - $this->assertEquals([ - 'variant' => 'foo', - 'name' => 'value', - ], Json::decode($encoded)); - - $decoded = $caster->get($model, 'property_morph_data', $encoded, []); - - $this->assertInstanceOf(StoredPropertyMorphFoo::class, $decoded); - $this->assertSame('value', $decoded->name); - } - - public function testAbstractDataRequiresAndRoundTripsAnEnforcedAlias(): void - { - $this->app->make(DataConfig::class)->enforceMorphMap([ - 'first' => StoredAbstractFirst::class, - ]); - - $caster = new DataEloquentCast(StoredAbstractData::class); - $model = new DataCastModel; - $encoded = $caster->set($model, 'abstract_data', new StoredAbstractFirst('value'), []); - - $this->assertSame([ - 'type' => 'first', - 'data' => ['name' => 'value'], - ], Json::decode($encoded)); - $this->assertEquals( - new StoredAbstractFirst('value'), - $caster->get($model, 'abstract_data', $encoded, []), - ); - } - - public function testEncryptedConcreteAndAbstractDataRoundTrip(): void + public function testAbstractDataRejectsStoredTypesOutsideTheDeclaredClass(): void { $this->app->make(DataConfig::class)->enforceMorphMap([ - 'first' => StoredAbstractFirst::class, - ]); - - $model = new DataCastModel; - $model->encrypted_data = new StoredSimpleData('concrete'); - $model->encrypted_abstract_data = new StoredAbstractFirst('abstract'); - - $encryptedConcrete = $model->getAttributes()['encrypted_data']; - $encryptedAbstract = $model->getAttributes()['encrypted_abstract_data']; - - $this->assertSame( - ['name' => 'concrete'], - Json::decode(Crypt::decryptString($encryptedConcrete)), - ); - $this->assertSame([ - 'type' => 'first', - 'data' => ['name' => 'abstract'], - ], Json::decode(Crypt::decryptString($encryptedAbstract))); - - $model = new DataCastModel; - $model->setRawAttributes([ - 'encrypted_data' => $encryptedConcrete, - 'encrypted_abstract_data' => $encryptedAbstract, - ]); - - $this->assertEquals(new StoredSimpleData('concrete'), $model->encrypted_data); - $this->assertEquals(new StoredAbstractFirst('abstract'), $model->encrypted_abstract_data); - } - - public function testAbstractDataRejectsValuesWithoutAnEnforcedAlias(): void - { - $caster = new DataEloquentCast(StoredAbstractData::class); - - $this->assertThrows( - fn () => $caster->set( - new DataCastModel, - 'abstract_data', - new StoredAbstractFirst('value'), - [], - ), - CannotCastData::class, - 'should have an enforced morph alias', - ); - } - - public function testAbstractDataRejectsUnknownFqcnAndInvalidMorphClasses(): void - { - $config = $this->app->make(DataConfig::class); - $config->enforceMorphMap([ 'unrelated' => StoredUnrelatedData::class, 'dto' => StoredDto::class, ]); + StoredUnrelatedFactory::$created = false; $caster = new DataEloquentCast(StoredAbstractData::class); $model = new DataCastModel; foreach ([ - ['missing', CannotCastData::class], - [StoredAbstractFirst::class, CannotCastData::class], - ['unrelated', CannotCastData::class], - ['dto', CannotCastData::class], - ] as [$alias, $exception]) { + 'missing', + 'Missing\Data\Class', + 'unrelated', + StoredUnrelatedData::class, + 'dto', + StoredAbstractData::class, + StoredUnrelatedFactory::class, + ] as $type) { $this->assertThrows( - fn () => $caster->get( + fn (): ?BaseData => $caster->get( $model, 'abstract_data', - json_encode(['type' => $alias, 'data' => ['name' => 'value']], JSON_THROW_ON_ERROR), + json_encode(['type' => $type, 'data' => ['name' => 'value']], JSON_THROW_ON_ERROR), [], ), - $exception, + CannotCastData::class, + 'should be a registered alias or a concrete transformable subtype', ); } + + // A stored class outside the declared type is rejected before anything is created from it. + $this->assertFalse(StoredUnrelatedFactory::$created); + $this->assertEquals( + new StoredAbstractFirst('value'), + $caster->get( + $model, + 'abstract_data', + json_encode(['type' => StoredAbstractFirst::class, 'data' => ['name' => 'value']], JSON_THROW_ON_ERROR), + [], + ), + ); } public function testDataCastRejectsInvalidAssignedValues(): void @@ -374,25 +607,6 @@ public function testDirtyComparisonHandlesNullAndDefaultValues(): void $this->assertFalse($model->isDirty('empty_default_data')); } - public function testEncryptedDirtyComparisonHonorsPreviousKeys(): void - { - $first = Crypt::encryptString('{"name":"Taylor"}'); - $second = Crypt::encryptString('{"name":"Taylor"}'); - $model = new DataCastModel; - $model->setRawAttributes(['encrypted_data' => $first], true); - $model->setRawAttributes(['encrypted_data' => $second]); - - $this->assertFalse($model->isDirty('encrypted_data')); - - try { - Crypt::previousKeys([random_bytes(32)]); - - $this->assertTrue($model->isDirty('encrypted_data')); - } finally { - Crypt::previousKeys([]); - } - } - /** * Assert dirty comparison through Eloquent's real class-cast caller. */ @@ -419,15 +633,10 @@ protected function casts(): array { return [ 'data' => StoredSimpleData::class, - 'default_data' => StoredDefaultData::class . ':default', 'empty_default_data' => StoredEmptyData::class . ':default', 'graph_data' => StoredGraphData::class, 'pair_data' => StoredPairData::class, 'override_data' => StoredOverrideData::class, - 'abstract_data' => StoredAbstractData::class, - 'encrypted_data' => StoredSimpleData::class . ':encrypted', - 'encrypted_abstract_data' => StoredAbstractData::class . ':encrypted', - 'property_morph_data' => StoredPropertyMorphData::class, ]; } } @@ -461,13 +670,6 @@ public function transform( } } -class StoredDefaultData extends Data -{ - public function __construct(public string $name = 'default') - { - } -} - class StoredEmptyData extends Data { } @@ -542,27 +744,51 @@ public function __construct(public string $name) } } -abstract class StoredPropertyMorphData extends Data implements PropertyMorphableData +class StoredUnrelatedFactory +{ + public static bool $created = false; + + /** + * Record that a value was created. + */ + public static function from(mixed ...$payloads): self + { + self::$created = true; + + return new self; + } +} + +abstract class TestCastAbstractPropertyMorphableData extends Data implements PropertyMorphableData { + /** + * Create a property-morphable fixture. + */ public function __construct( #[PropertyForMorph] - public string $variant, + public DummyBackedEnum $variant ) { } + /** + * Get the subclass for the variant. + */ public static function morph(array $properties): ?string { return match ($properties['variant'] ?? null) { - 'foo' => StoredPropertyMorphFoo::class, + DummyBackedEnum::FOO => TestCastPropertyMorphableDataFoo::class, default => null, }; } } -class StoredPropertyMorphFoo extends StoredPropertyMorphData +class TestCastPropertyMorphableDataFoo extends TestCastAbstractPropertyMorphableData { - public function __construct(public string $name) + /** + * Create the foo variant. + */ + public function __construct(public string $a) { - parent::__construct('foo'); + parent::__construct(DummyBackedEnum::FOO); } } diff --git a/tests/Data/EmptyTest.php b/tests/Data/EmptyTest.php new file mode 100644 index 0000000000..5ddee2d367 --- /dev/null +++ b/tests/Data/EmptyTest.php @@ -0,0 +1,85 @@ +assertSame([ + 'property' => null, + 'lazyProperty' => null, + 'array' => [], + 'collection' => [], + 'dataCollection' => [], + 'data' => [ + 'string' => null, + ], + 'lazyData' => [ + 'string' => null, + ], + 'defaultProperty' => true, + ], $dataClass::empty()); + } + + public function testCanOverwritePropertiesInAnEmptyVersionOfADataObject(): void + { + $this->assertSame([ + 'string' => null, + ], SimpleData::empty()); + + $this->assertSame([ + 'string' => 'Ruben', + ], SimpleData::empty(['string' => 'Ruben'])); + } + + public function testCanUseExceptToFilterOutPropertiesInAnEmptyVersionOfADataObject(): void + { + $this->assertSame(['first' => null], MultiData::empty(except: ['second'])); + } + + public function testCanUseOnlyToFilterOutPropertiesInAnEmptyVersionOfADataObject(): void + { + $this->assertSame(['second' => null], MultiData::empty(only: ['second'])); + } +} diff --git a/tests/Data/Fixtures/AbstractData/AbstractData.php b/tests/Data/Fixtures/AbstractData/AbstractData.php new file mode 100644 index 0000000000..b188c23599 --- /dev/null +++ b/tests/Data/Fixtures/AbstractData/AbstractData.php @@ -0,0 +1,11 @@ + PropertyMorphableDataA::class, + PropertyMorphableEnum::B => PropertyMorphableDataB::class, + default => null, + }; + } +} diff --git a/tests/Data/Fixtures/Casts/ConfidentialDataCast.php b/tests/Data/Fixtures/Casts/ConfidentialDataCast.php new file mode 100644 index 0000000000..b6fc396a9c --- /dev/null +++ b/tests/Data/Fixtures/Casts/ConfidentialDataCast.php @@ -0,0 +1,21 @@ +castValue($value); + } + + /** + * Uppercase an iterable item. + */ + public function castIterableItem(DataProperty $property, mixed $value, array $properties, CreationContext $context): string + { + return $this->castValue($value); + } + + /** + * Uppercase a string value. + */ + private function castValue(mixed $value): string + { + return strtoupper($value); + } +} diff --git a/tests/Data/Fixtures/CircData.php b/tests/Data/Fixtures/CircData.php new file mode 100644 index 0000000000..9ebe414a08 --- /dev/null +++ b/tests/Data/Fixtures/CircData.php @@ -0,0 +1,19 @@ + + */ +class SimpleDataCollection extends Collection +{ +} diff --git a/tests/Data/Fixtures/Collections/SimpleDataCollectionWithAnotations.php b/tests/Data/Fixtures/Collections/SimpleDataCollectionWithAnotations.php new file mode 100644 index 0000000000..62cb14443d --- /dev/null +++ b/tests/Data/Fixtures/Collections/SimpleDataCollectionWithAnotations.php @@ -0,0 +1,17 @@ + + */ +class SimpleDataCollectionWithAnotations extends Collection +{ +} diff --git a/tests/Data/Fixtures/ComplicatedData.php b/tests/Data/Fixtures/ComplicatedData.php new file mode 100644 index 0000000000..ed59a5c7ce --- /dev/null +++ b/tests/Data/Fixtures/ComplicatedData.php @@ -0,0 +1,43 @@ + $parameters + */ + protected function bindRouteParameters(array $parameters): Request + { + $request = Request::create('/posts'); + $route = (new Route('GET', '/posts', static fn (): null => null))->bind($request); + + foreach ($parameters as $name => $value) { + $route->setParameter($name, $value); + } + + $request->setRouteResolver(static fn (): Route => $route); + RequestContext::set($request); + + return $request; + } +} diff --git a/tests/Data/Fixtures/DataCollections/CustomDataCollection.php b/tests/Data/Fixtures/DataCollections/CustomDataCollection.php new file mode 100644 index 0000000000..923b4b5c68 --- /dev/null +++ b/tests/Data/Fixtures/DataCollections/CustomDataCollection.php @@ -0,0 +1,11 @@ + $dataClass + */ + public function __construct( + protected readonly string $dataClass, + ) { + } + + /** + * Create an asserter for a data class or object. + * + * @param class-string|ValidateableData $dataClass + */ + public static function for(string|ValidateableData $dataClass): self + { + return new self(is_object($dataClass) ? $dataClass::class : $dataClass); + } + + /** + * Assert the payload passes validation. + */ + public function assertOk(array $payload): self + { + $this->dataClass::validate($payload); + + Assert::assertTrue(true); + + return $this; + } + + /** + * Assert the payload fails validation, optionally with exactly the given errors. + */ + public function assertErrors(array $payload, ?array $errors = null): self + { + $exception = $this->validationException($payload); + + if ($errors === null) { + Assert::assertNotEmpty($exception->errors()); + } else { + Assert::assertSame($errors, $exception->errors()); + } + + return $this; + } + + /** + * Assert the rules compiled for the payload, expanded against the payload like the validator does. + */ + public function assertRules(array $rules, array $payload = []): self + { + $parser = new ValidationRuleParser($payload); + + Assert::assertEquals( + $this->normalizeDatabaseRules($rules), + $this->normalizeDatabaseRules($parser->explode($this->dataClass::getValidationRules($payload))->rules), + ); + + return $this; + } + + /** + * Assert the payload fails validation with the given redirect. + */ + public function assertRedirect(array $payload, string $redirect): self + { + Assert::assertSame($redirect, $this->validationException($payload)->redirectTo); + + return $this; + } + + /** + * Assert the payload fails validation with the given error bag. + */ + public function assertErrorBag(array $payload, string $errorBag): self + { + Assert::assertSame($errorBag, $this->validationException($payload)->errorBag); + + return $this; + } + + /** + * Assert the custom messages given to the validator for the payload. + */ + public function assertMessages(array $messages, array $payload = []): self + { + Assert::assertEquals($messages, $this->validator($payload)->customMessages); + + return $this; + } + + /** + * Assert the custom attribute names given to the validator for the payload. + */ + public function assertAttributes(array $attributes, array $payload = []): self + { + Assert::assertEquals($attributes, $this->validator($payload)->customAttributes); + + return $this; + } + + /** + * Validate the payload and return the exception it must fail with. + */ + protected function validationException(array $payload): ValidationException + { + try { + $this->dataClass::validate($payload); + } catch (ValidationException $exception) { + return $exception; + } + + Assert::fail('No validation errors'); + } + + /** + * Get the validator configured for the payload. + */ + protected function validator(array $payload): Validator + { + $validator = null; + + try { + $this->dataClass::factory() + ->withValidator(static function (Validator $instance) use (&$validator): void { + $validator = $instance; + }) + ->validate($payload); + } catch (ValidationException) { + // Only the configured validator is needed; the payload may fail. + } + + return $validator; + } + + /** + * Replace database rules that have query callbacks with the query those callbacks build. + * + * Callbacks are closures that cannot be compared, so the query they build shows what they do. + */ + protected function normalizeDatabaseRules(array $rules): array + { + return array_map( + static fn (array $fieldRules): array => array_map( + static function (mixed $rule): mixed { + if (! ($rule instanceof Exists || $rule instanceof Unique) || $rule->queryCallbacks() === []) { + return $rule; + } + + $query = DB::query(); + + // As DatabasePresenceVerifier does, each callback receives its own nested where group. + foreach ($rule->queryCallbacks() as $callback) { + $query->where(static function (Builder $query) use ($callback): void { + $callback($query); + }); + } + + return [(string) $rule, $query->toSql(), $query->getBindings()]; + }, + $fieldRules, + ), + $rules, + ); + } +} diff --git a/tests/Data/Fixtures/DataWithMapper.php b/tests/Data/Fixtures/DataWithMapper.php new file mode 100644 index 0000000000..09b1e595a8 --- /dev/null +++ b/tests/Data/Fixtures/DataWithMapper.php @@ -0,0 +1,21 @@ + $name)->defaultIncluded() + ); + } + + /** + * Get the excludes a request may ask for. + */ + public static function allowedRequestExcludes(): ?array + { + return self::$allowedExcludes; + } + + /** + * Set the excludes a request may ask for. + */ + public static function setAllowedExcludes(?array $allowedExcludes): void + { + self::$allowedExcludes = $allowedExcludes; + } +} diff --git a/tests/Data/Fixtures/DummyDataWithContextOverwrittenValidationRules.php b/tests/Data/Fixtures/DummyDataWithContextOverwrittenValidationRules.php new file mode 100644 index 0000000000..af52b5c046 --- /dev/null +++ b/tests/Data/Fixtures/DummyDataWithContextOverwrittenValidationRules.php @@ -0,0 +1,27 @@ +payload['validate_as_email'] ?? false + ? ['string' => ['required', 'string', 'email']] + : []; + } +} diff --git a/tests/Data/Fixtures/DummyDto.php b/tests/Data/Fixtures/DummyDto.php new file mode 100644 index 0000000000..b5d5d7cad1 --- /dev/null +++ b/tests/Data/Fixtures/DummyDto.php @@ -0,0 +1,34 @@ + $this->faker->name, + 'nullable' => null, + 'date' => $this->faker->dateTime(), + ]; + } +} diff --git a/tests/Data/Fixtures/Factories/FakeNestedModelFactory.php b/tests/Data/Fixtures/Factories/FakeNestedModelFactory.php new file mode 100644 index 0000000000..8385871270 --- /dev/null +++ b/tests/Data/Fixtures/Factories/FakeNestedModelFactory.php @@ -0,0 +1,27 @@ + $this->faker->name, + 'nullable' => null, + 'date' => $this->faker->dateTime(), + 'fake_model_id' => FakeModel::factory(), + ]; + } +} diff --git a/tests/Data/Fixtures/FakeModelData.php b/tests/Data/Fixtures/FakeModelData.php new file mode 100644 index 0000000000..0382922649 --- /dev/null +++ b/tests/Data/Fixtures/FakeModelData.php @@ -0,0 +1,29 @@ +string, + $model->nullable, + $model->date, + Lazy::whenLoaded('fakeModel', $model, fn (): FakeModelData => FakeModelData::from($model->fakeModel)), + ); + } +} diff --git a/tests/Data/Fixtures/LazyData.php b/tests/Data/Fixtures/LazyData.php new file mode 100644 index 0000000000..3659a29bd7 --- /dev/null +++ b/tests/Data/Fixtures/LazyData.php @@ -0,0 +1,45 @@ + $name)); + } + + /** + * Get the includes a request may ask for. + */ + public static function allowedRequestIncludes(): ?array + { + return self::$allowedIncludes; + } + + /** + * Set the includes a request may ask for. + */ + public static function setAllowedIncludes(?array $allowedIncludes): void + { + self::$allowedIncludes = $allowedIncludes; + } +} diff --git a/tests/Data/Fixtures/Migrations/2022_02_12_145756_create_fake_tables.php b/tests/Data/Fixtures/Migrations/2022_02_12_145756_create_fake_tables.php new file mode 100644 index 0000000000..3dbabd1842 --- /dev/null +++ b/tests/Data/Fixtures/Migrations/2022_02_12_145756_create_fake_tables.php @@ -0,0 +1,38 @@ +bigIncrements('id'); + + $table->string('string'); + $table->string('nullable')->nullable(); + $table->string('date'); + + $table->timestamps(); + }); + + Schema::create('fake_nested_models', function (Blueprint $table): void { + $table->bigIncrements('id'); + + $table->string('string'); + $table->string('nullable')->nullable(); + $table->string('date'); + + $table->unsignedBigInteger('fake_model_id')->nullable(); + $table->foreign('fake_model_id')->references('id')->on('fake_models')->cascadeOnDelete(); + + $table->timestamps(); + }); + } +}; diff --git a/tests/Data/Fixtures/Migrations/2022_02_12_145757_create_dummy_model_tables.php b/tests/Data/Fixtures/Migrations/2022_02_12_145757_create_dummy_model_tables.php new file mode 100644 index 0000000000..91d855b940 --- /dev/null +++ b/tests/Data/Fixtures/Migrations/2022_02_12_145757_create_dummy_model_tables.php @@ -0,0 +1,33 @@ +increments('id'); + + $table->text('data')->nullable(); + $table->text('lazy_data')->nullable(); + $table->text('data_collection')->nullable(); + $table->text('lazy_data_collection')->nullable(); + $table->text('abstract_data')->nullable(); + $table->text('abstract_collection')->nullable(); + }); + + Schema::create('dummy_model_with_jsons', function (Blueprint $table): void { + $table->increments('id'); + + $table->jsonb('data')->nullable(); + $table->jsonb('data_collection')->nullable(); + }); + } +}; diff --git a/tests/Data/Fixtures/Models/DummyModel.php b/tests/Data/Fixtures/Models/DummyModel.php new file mode 100644 index 0000000000..58a2c46840 --- /dev/null +++ b/tests/Data/Fixtures/Models/DummyModel.php @@ -0,0 +1,41 @@ + 'datetime', + 'nullable_date' => 'datetime', + 'optional_date' => 'datetime', + 'nullable_optional_date' => 'datetime', + 'boolean' => 'boolean', + ]; + + /** + * Create the dummy model table. + */ + public static function migrate(): void + { + Schema::create('dummy_models', function (Blueprint $blueprint): void { + $blueprint->increments('id'); + + $blueprint->string('string'); + $blueprint->dateTime('date'); + $blueprint->dateTime('nullable_date')->nullable(); + $blueprint->dateTime('optional_date')->nullable(); + $blueprint->dateTime('nullable_optional_date')->nullable(); + $blueprint->boolean('boolean'); + + $blueprint->timestamps(); + }); + } +} diff --git a/tests/Data/Fixtures/Models/DummyModelWithCasts.php b/tests/Data/Fixtures/Models/DummyModelWithCasts.php new file mode 100644 index 0000000000..5d68a12ee8 --- /dev/null +++ b/tests/Data/Fixtures/Models/DummyModelWithCasts.php @@ -0,0 +1,27 @@ + SimpleData::class, + 'lazy_data' => LazyData::class, + 'data_collection' => DataCollection::class . ':' . SimpleData::class, + 'lazy_data_collection' => DataCollection::class . ':' . LazyData::class, + 'abstract_data' => AbstractData::class, + 'abstract_collection' => DataCollection::class . ':' . AbstractData::class, + ]; + + public bool $timestamps = false; +} diff --git a/tests/Data/Fixtures/Models/DummyModelWithCustomCollectionCasts.php b/tests/Data/Fixtures/Models/DummyModelWithCustomCollectionCasts.php new file mode 100644 index 0000000000..537e0ca77f --- /dev/null +++ b/tests/Data/Fixtures/Models/DummyModelWithCustomCollectionCasts.php @@ -0,0 +1,18 @@ + SimpleData::class, + 'data_collection' => SimpleDataCollection::class . ':' . SimpleData::class, + ]; +} diff --git a/tests/Data/Fixtures/Models/DummyModelWithDefaultCasts.php b/tests/Data/Fixtures/Models/DummyModelWithDefaultCasts.php new file mode 100644 index 0000000000..27d9642d8f --- /dev/null +++ b/tests/Data/Fixtures/Models/DummyModelWithDefaultCasts.php @@ -0,0 +1,22 @@ + SimpleDataWithDefaultValue::class . ':default', + 'data_collection' => SimpleDataCollection::class . ':' . SimpleData::class . ',default', + ]; + + protected ?string $table = 'dummy_model_with_casts'; + + public bool $timestamps = false; +} diff --git a/tests/Data/Fixtures/Models/DummyModelWithEncryptedCasts.php b/tests/Data/Fixtures/Models/DummyModelWithEncryptedCasts.php new file mode 100644 index 0000000000..ef775885ad --- /dev/null +++ b/tests/Data/Fixtures/Models/DummyModelWithEncryptedCasts.php @@ -0,0 +1,27 @@ + SimpleData::class . ':encrypted', + 'data_collection' => SimpleDataCollection::class . ':' . SimpleData::class . ',encrypted', + 'abstract_data' => AbstractData::class . ':encrypted', + 'abstract_collection' => DataCollection::class . ':' . AbstractData::class . ',encrypted', + ]; + + protected ?string $table = 'dummy_model_with_casts'; + + public bool $timestamps = false; +} diff --git a/tests/Data/Fixtures/Models/DummyModelWithJson.php b/tests/Data/Fixtures/Models/DummyModelWithJson.php new file mode 100644 index 0000000000..4a9c63d824 --- /dev/null +++ b/tests/Data/Fixtures/Models/DummyModelWithJson.php @@ -0,0 +1,19 @@ + MultiData::class, + 'data_collection' => DataCollection::class . ':' . MultiData::class, + ]; + + public bool $timestamps = false; +} diff --git a/tests/Data/Fixtures/Models/FakeModel.php b/tests/Data/Fixtures/Models/FakeModel.php new file mode 100644 index 0000000000..ea79e5a828 --- /dev/null +++ b/tests/Data/Fixtures/Models/FakeModel.php @@ -0,0 +1,111 @@ + 'immutable_datetime', + ]; + + /** + * Get the fake nested models. + */ + public function fakeNestedModels(): HasMany + { + return $this->hasMany(FakeNestedModel::class); + } + + /** + * Get the fake nested models through a snake-cased relation name. + */ + public function fake_nested_models_snake_cased(): HasMany + { + return $this->hasMany(FakeNestedModel::class); + } + + /** + * Get the accessor attribute. + */ + public function accessor(): Attribute + { + return Attribute::get(fn (): string => "accessor_{$this->string}"); + } + + /** + * Get the old-style accessor attribute. + */ + public function getOldAccessorAttribute(): string + { + return "old_accessor_{$this->string}"; + } + + /** + * Get an old-style accessor that must not be evaluated. + */ + public function getPerformanceHeavyAttribute(): never + { + throw new Exception('This attribute should not be called'); + } + + /** + * Get an accessor that must not be evaluated. + */ + public function performanceHeavyAccessor(): Attribute + { + return Attribute::get(fn (): never => throw new Exception('This accessor should not be called')); + } + + /** + * Get an accessor that reads a relation. + */ + public function getAccessorUsingRelationAttribute(): string + { + return $this->fakeNestedModels->first()->string; + } + + /** + * Create a new factory instance for the model. + */ + protected static function newFactory(): FakeModelFactory + { + return FakeModelFactory::new(); + } + + /** + * Get the string attribute. + * + * Method to confuse isRelation, see https://github.com/spatie/laravel-data/issues/978 + */ + public function string(): string + { + return $this->string; + } + + /** + * Get an attribute from the model. + * + * Method to check for compatibility with astrotomic/translatable + */ + public function getAttribute(string $key): mixed + { + if ($key === 'translated') { + return 'translated_string'; + } + + return parent::getAttribute($key); + } +} diff --git a/tests/Data/Fixtures/Models/FakeNestedModel.php b/tests/Data/Fixtures/Models/FakeNestedModel.php new file mode 100644 index 0000000000..3e91591141 --- /dev/null +++ b/tests/Data/Fixtures/Models/FakeNestedModel.php @@ -0,0 +1,37 @@ + 'immutable_datetime', + ]; + + /** + * Get the parent fake model. + */ + public function fakeModel(): BelongsTo + { + return $this->belongsTo(FakeModel::class); + } + + /** + * Create a new factory instance for the model. + */ + protected static function newFactory(): FakeNestedModelFactory + { + return FakeNestedModelFactory::new(); + } +} diff --git a/tests/Data/Fixtures/MultiData.php b/tests/Data/Fixtures/MultiData.php new file mode 100644 index 0000000000..07723424b3 --- /dev/null +++ b/tests/Data/Fixtures/MultiData.php @@ -0,0 +1,19 @@ + $artist), + Lazy::create(fn (): string => $name), + Lazy::create(fn (): int => $year), + ); + } + + /** + * Create the fixture from a song transfer object. + */ + public static function fromDto(DummyDto $dto): static + { + return new static( + Lazy::create(fn (): string => $dto->artist), + Lazy::create(fn (): string => $dto->name), + Lazy::create(fn (): int => $dto->year), + ); + } +} diff --git a/tests/Data/Fixtures/MultiNamespacePhpDocTypes.php b/tests/Data/Fixtures/MultiNamespacePhpDocTypes.php index ea4709cab0..e148f87c76 100644 --- a/tests/Data/Fixtures/MultiNamespacePhpDocTypes.php +++ b/tests/Data/Fixtures/MultiNamespacePhpDocTypes.php @@ -22,4 +22,13 @@ class MultiNamespaceFirst class MultiNamespaceSecond { } + + /** + * Create an anonymous class whose parent belongs to the first namespace. + */ + function multiNamespaceAnonymous(): object + { + return new class extends \Hypervel\Tests\Data\Fixtures\First\MultiNamespaceFirst { + }; + } } diff --git a/tests/Data/Fixtures/MultiNestedData.php b/tests/Data/Fixtures/MultiNestedData.php new file mode 100644 index 0000000000..d1717080e5 --- /dev/null +++ b/tests/Data/Fixtures/MultiNestedData.php @@ -0,0 +1,21 @@ + SimpleData::from($string))); + } +} diff --git a/tests/Data/Fixtures/NestedNullableData.php b/tests/Data/Fixtures/NestedNullableData.php new file mode 100644 index 0000000000..b697b12171 --- /dev/null +++ b/tests/Data/Fixtures/NestedNullableData.php @@ -0,0 +1,18 @@ + 'Hello World'), + Lazy::create(fn (): SimpleData => SimpleData::from('Hello World')) + ); + } + + /** + * Create the fixture with lazy values included by default. + */ + public static function createDefaultIncluded(bool $enabled): self + { + return new self( + $enabled, + Lazy::create(fn (): string => 'Hello World')->defaultIncluded(), + Lazy::create(fn (): SimpleData => SimpleData::from('Hello World'))->defaultIncluded() + ); + } +} diff --git a/tests/Data/Fixtures/PropertyMorphableDataA.php b/tests/Data/Fixtures/PropertyMorphableDataA.php new file mode 100644 index 0000000000..810a9510e3 --- /dev/null +++ b/tests/Data/Fixtures/PropertyMorphableDataA.php @@ -0,0 +1,21 @@ +only([DummyBackedEnum::FOO]), + expectCreatedAttribute: new Enum((new EnumRule(DummyBackedEnum::class))->only(DummyBackedEnum::FOO)) + ); + + yield fixature( + attribute: new Enum(DummyBackedEnum::class, except: [DummyBackedEnum::FOO]), + expected: (new EnumRule(DummyBackedEnum::class))->except([DummyBackedEnum::FOO]), + expectCreatedAttribute: new Enum((new EnumRule(DummyBackedEnum::class))->except(DummyBackedEnum::FOO)) + ); + + yield fixature( + attribute: new ExcludeIf('field', true), + expected: 'exclude_if:field,true', + expectCreatedAttribute: new ExcludeIf('field', true) + ); + + yield fixature( + attribute: new ExcludeUnless('field', 42), + expected: 'exclude_unless:field,42', + ); + + yield fixature( + attribute: new ExcludeWith('field'), + expected: 'exclude_with:field', + ); + + yield fixature( + attribute: new ExcludeWithout('field'), + expected: 'exclude_without:field', + ); + + yield fixature( + attribute: new File, + expected: 'file', + ); + + yield fixature( + attribute: new Filled, + expected: 'filled', + ); + + yield fixature( + attribute: new GreaterThan('field'), + expected: 'gt:field', + ); + + yield fixature( + attribute: new GreaterThan(10), + expected: 'gt:10', + ); + + yield fixature( + attribute: new GreaterThanOrEqualTo('field'), + expected: 'gte:field', + ); + + yield fixature( + attribute: new GreaterThanOrEqualTo('10'), + expected: 'gte:10', + ); + + yield fixature( + attribute: new Image, + expected: 'image', + ); + + yield fixature( + attribute: new InArray('field'), + expected: 'in_array:field', + ); + + yield fixature( + attribute: new IntegerType, + expected: 'integer', + ); + + yield fixature( + attribute: new IP, + expected: 'ip', + ); + + yield fixature( + attribute: new IPv4, + expected: 'ipv4', + ); + + yield fixature( + attribute: new IPv6, + expected: 'ipv6', + ); + + yield fixature( + attribute: new Json, + expected: 'json', + ); + + yield fixature( + attribute: new LessThan('field'), + expected: 'lt:field', + ); + + yield fixature( + attribute: new LessThan(10), + expected: 'lt:10', + ); + + yield fixature( + attribute: new LessThanOrEqualTo('field'), + expected: 'lte:field', + ); + + yield fixature( + attribute: new LessThanOrEqualTo(10), + expected: 'lte:10', + ); + + yield fixature( + attribute: new ListType, + expected: 'list', + ); + + yield fixature( + attribute: new Lowercase, + expected: 'lowercase', + ); + + yield fixature( + attribute: new MacAddress, + expected: 'mac_address', + ); + + yield fixature( + attribute: new Max(10), + expected: 'max:10', + ); + + yield fixature( + attribute: new Max(10.5), + expected: 'max:10.5', + ); + + yield fixature( + attribute: new MaxDigits(10), + expected: 'max_digits:10', + ); + + yield fixature( + attribute: new Min(10), + expected: 'min:10', + ); + + yield fixature( + attribute: new Min(0.01), + expected: 'min:0.01', + ); + + yield fixature( + attribute: new MinDigits(10), + expected: 'min_digits:10', + ); + + yield fixature( + attribute: new MultipleOf(10), + expected: 'multiple_of:10', + ); + + yield fixature( + attribute: new NotRegex('/^.+$/i'), + expected: 'not_regex:/^.+$/i', + ); + + yield fixature( + attribute: new Nullable, + expected: 'nullable', + ); + + yield fixature( + attribute: new Numeric, + expected: 'numeric', + ); + + yield fixature( + attribute: new Present, + expected: 'present', + ); + + yield fixature( + attribute: new Prohibited, + expected: 'prohibited', + ); + + yield fixature( + attribute: new Regex('/^.+$/i'), + expected: 'regex:/^.+$/i', + ); + + yield fixature( + attribute: new Required, + expected: 'required', + ); + + yield fixature( + attribute: new Same('field'), + expected: 'same:field', + ); + + yield fixature( + attribute: new Size(10), + expected: 'size:10', + ); + + yield fixature( + attribute: new Size(0.5), + expected: 'size:0.5', + ); + + yield fixature( + attribute: new StringType, + expected: 'string', + ); + + yield fixature( + attribute: new Timezone, + expected: 'timezone', + ); + + yield fixature( + attribute: new Uppercase, + expected: 'uppercase', + ); + + yield from urlAttributes(); + + yield fixature( + attribute: new Ulid, + expected: 'ulid', + ); + + yield fixature( + attribute: new Uuid, + expected: 'uuid', + ); + + yield fixature( + attribute: new Sometimes, + expected: 'sometimes', + ); +} + +function acceptedIfAttributes(): Generator +{ + yield fixature( + attribute: new AcceptedIf('value', 'string'), + expected: 'accepted_if:value,string', + ); + + yield fixature( + attribute: new AcceptedIf('value', true), + expected: 'accepted_if:value,true', + ); + + yield fixature( + attribute: new AcceptedIf('value', 42), + expected: 'accepted_if:value,42', + ); + + yield fixature( + attribute: new AcceptedIf('value', 3.14), + expected: 'accepted_if:value,3.14', + ); + + yield fixature( + attribute: new AcceptedIf('value', DummyBackedEnum::FOO), + expected: 'accepted_if:value,foo', + expectCreatedAttribute: new AcceptedIf('value', 'foo') + ); +} + +function urlAttributes(): Generator +{ + yield fixature( + attribute: new Url, + expected: 'url', + ); + + yield fixature( + attribute: new Url('http'), + expected: 'url:http', + ); + + yield fixature( + attribute: new Url(['http', 'https']), + expected: 'url:http,https', + ); + + yield fixature( + attribute: new Url('http', 'https'), + expected: 'url:http,https', + ); +} + +function afterAttributes(): Generator +{ + yield fixature( + attribute: new After('some_field'), + expected: 'after:some_field', + ); + + yield fixature( + attribute: new After('2020-05-15 00:00:00'), + expected: 'after:2020-05-15 00:00:00', + ); +} + +function afterOrEqualAttributes(): Generator +{ + yield fixature( + attribute: new AfterOrEqual('some_field'), + expected: 'after_or_equal:some_field', + ); + + yield fixature( + attribute: new AfterOrEqual('2020-05-15 00:00:00'), + expected: 'after_or_equal:2020-05-15 00:00:00', + ); +} + +function arrayTypeAttributes(): Generator +{ + yield fixature( + attribute: new ArrayType, + expected: 'array', + ); + + yield fixature( + attribute: new ArrayType(['a', 'b', 'c']), + expected: 'array:a,b,c', + ); + + yield fixature( + attribute: new ArrayType('a', 'b', 'c'), + expected: 'array:a,b,c', + ); + + yield fixature( + attribute: new ArrayType('a', ['b', 'c']), + expected: 'array:a,b,c', + ); +} + +function beforeAttributes(): Generator +{ + yield fixature( + attribute: new Before('some_field'), + expected: 'before:some_field', + ); + + yield fixature( + attribute: new Before('2020-05-15 00:00:00'), + expected: 'before:2020-05-15 00:00:00', + ); +} + +function beforeOrEqualAttributes(): Generator +{ + yield fixature( + attribute: new BeforeOrEqual('some_field'), + expected: 'before_or_equal:some_field', + ); + + yield fixature( + attribute: new BeforeOrEqual('2020-05-15 00:00:00'), + expected: 'before_or_equal:2020-05-15 00:00:00', + ); +} + +function betweenAttributes(): Generator +{ + yield fixature( + attribute: new Between(-42, 42), + expected: 'between:-42,42', + ); + + yield fixature( + attribute: new Between(-3.14, 3.14), + expected: 'between:-3.14,3.14', + ); + + yield fixature( + attribute: new Between(-3.14, 42), + expected: 'between:-3.14,42', + ); +} + +function currentPasswordAttributes(): Generator +{ + yield fixature( + attribute: new CurrentPassword, + expected: 'current_password', + ); + + yield fixature( + attribute: new CurrentPassword('api'), + expected: 'current_password:api', + ); + + yield fixature( + attribute: new CurrentPassword(DummyBackedEnum::FOO), + expected: 'current_password:foo', + expectCreatedAttribute: new CurrentPassword(DummyBackedEnum::FOO->value) + ); +} + +function dateEqualsAttributes(): Generator +{ + yield fixature( + attribute: new DateEquals('tomorrow'), + expected: 'date_equals:tomorrow', + ); + + yield fixature( + attribute: new DateEquals('2020-05-15 00:00:00'), + expected: 'date_equals:2020-05-15 00:00:00', + ); +} + +function dateFormatAttributes(): Generator +{ + yield fixature( + attribute: new DateFormat('Y-m-d'), + expected: 'date_format:Y-m-d', + ); + + yield fixature( + attribute: new DateFormat(['Y-m-d', 'Y-m-d H:i:s']), + expected: 'date_format:Y-m-d,Y-m-d H:i:s', + ); + + yield fixature( + attribute: new DateFormat('Y-m-d', 'Y-m-d H:i:s'), + expected: 'date_format:Y-m-d,Y-m-d H:i:s', + ); +} + +function dimensionsAttributes(): Generator +{ + yield fixature( + attribute: new Dimensions(minWidth: 15, minHeight: 10, maxWidth: 150, maxHeight: 100, ratio: 1), + expected: 'dimensions:min_width=15,min_height=10,max_width=150,max_height=100,ratio=1', + ); + + yield fixature( + attribute: new Dimensions(maxWidth: 150, maxHeight: 100), + expected: 'dimensions:max_width=150,max_height=100', + ); + + yield fixature( + attribute: new Dimensions(ratio: 1.5), + expected: 'dimensions:ratio=1.5', + ); + + yield fixature( + attribute: new Dimensions(ratio: '3/4'), + expected: 'dimensions:ratio=3/4', + ); +} + +function distinctAttributes(): Generator +{ + yield fixature( + attribute: new Distinct, + expected: 'distinct', + ); + + yield fixature( + attribute: new Distinct(Distinct::Strict), + expected: 'distinct:strict', + ); + + yield fixature( + attribute: new Distinct(Distinct::IgnoreCase), + expected: 'distinct:ignore_case', + ); + + yield fixature( + attribute: new Distinct('fake'), + expected: '', + exception: CannotBuildValidationRule::class + ); +} + +function doesntEndWithAttributes(): Generator +{ + yield fixature( + attribute: new DoesntEndWith('x'), + expected: 'doesnt_end_with:x', + ); + + yield fixature( + attribute: new DoesntEndWith(['x', 'y']), + expected: 'doesnt_end_with:x,y', + ); + + yield fixature( + attribute: new DoesntEndWith('x', 'y'), + expected: 'doesnt_end_with:x,y', + ); +} + +function doesntStartWithAttributes(): Generator +{ + yield fixature( + attribute: new DoesntStartWith('x'), + expected: 'doesnt_start_with:x', + ); + + yield fixature( + attribute: new DoesntStartWith(['x', 'y']), + expected: 'doesnt_start_with:x,y', + ); + + yield fixature( + attribute: new DoesntStartWith('x', 'y'), + expected: 'doesnt_start_with:x,y', + ); +} + +function declinedIfAttributes(): Generator +{ + yield fixature( + attribute: new DeclinedIf('value', 'string'), + expected: 'declined_if:value,string', + ); + + yield fixature( + attribute: new DeclinedIf('value', true), + expected: 'declined_if:value,true', + ); + + yield fixature( + attribute: new DeclinedIf('value', 42), + expected: 'declined_if:value,42', + ); + + yield fixature( + attribute: new DeclinedIf('value', 3.14), + expected: 'declined_if:value,3.14', + ); + + yield fixature( + attribute: new DeclinedIf('value', DummyBackedEnum::FOO), + expected: 'declined_if:value,foo', + expectCreatedAttribute: new DeclinedIf('value', 'foo') + ); +} + +function emailAttributes(): Generator +{ + yield fixature( + attribute: new Email, + expected: 'email:rfc', + expectCreatedAttribute: new Email(Email::RfcValidation), + ); + + yield fixature( + attribute: new Email(Email::RfcValidation), + expected: 'email:rfc', + ); + + yield fixature( + attribute: new Email([Email::RfcValidation, Email::NoRfcWarningsValidation]), + expected: 'email:rfc,strict', + ); + + yield fixature( + attribute: new Email([Email::RfcValidation, Email::NoRfcWarningsValidation, Email::DnsCheckValidation, Email::SpoofCheckValidation, Email::FilterEmailValidation]), + expected: 'email:rfc,strict,dns,spoof,filter', + ); + + yield fixature( + attribute: new Email(Email::RfcValidation, Email::NoRfcWarningsValidation), + expected: 'email:rfc,strict', + ); + + yield fixature( + attribute: new Email(['fake']), + expected: '', + exception: CannotBuildValidationRule::class, + ); +} + +function endsWithAttributes(): Generator +{ + yield fixature( + attribute: new EndsWith('x'), + expected: 'ends_with:x', + ); + + yield fixature( + attribute: new EndsWith(['x', 'y']), + expected: 'ends_with:x,y', + ); + + yield fixature( + attribute: new EndsWith('x', 'y'), + expected: 'ends_with:x,y', + ); +} + +function existsAttributes(): Generator +{ + yield fixature( + attribute: new Exists('users'), + expected: new BaseExists('users'), + expectCreatedAttribute: new Exists(rule: new BaseExists('users')) + ); + + yield fixature( + attribute: new Exists('users', 'email'), + expected: new BaseExists('users', 'email'), + expectCreatedAttribute: new Exists(rule: new BaseExists('users', 'email')) + ); + + yield fixature( + attribute: new Exists('users', 'email', connection: 'tenant'), + expected: new BaseExists('tenant.users', 'email'), + expectCreatedAttribute: new Exists(rule: new BaseExists('tenant.users', 'email')) + ); + + $closure = fn (Builder $builder): Builder => $builder; + + yield fixature( + attribute: new Exists('users', 'email', where: $closure), + expected: (new BaseExists('users', 'email'))->where($closure), + expectCreatedAttribute: new Exists(rule: (new BaseExists('users', 'email'))->where($closure)) + ); + + yield fixature( + attribute: new Exists('users', 'email', withoutTrashed: true), + expected: (new BaseExists('users', 'email'))->withoutTrashed(), + expectCreatedAttribute: new Exists(rule: (new BaseExists('users', 'email'))->withoutTrashed()) + ); + + yield fixature( + attribute: new Exists('users', 'email', withoutTrashed: true, deletedAtColumn: 'deleted_when'), + expected: (new BaseExists('users', 'email'))->withoutTrashed('deleted_when'), + expectCreatedAttribute: new Exists(rule: (new BaseExists('users', 'email'))->withoutTrashed('deleted_when')) + ); + + yield fixature( + attribute: new Exists('users', 'id', where: new WhereConstraint('active', true)), + expected: (new BaseExists('users', 'id'))->where('active', true), + expectCreatedAttribute: new Exists(rule: (new BaseExists('users', 'id'))->where('active', true)) + ); + + yield fixature( + attribute: new Exists('users', 'email', where: new WhereNotConstraint('name', 'Unlucky')), + expected: (new BaseExists('users', 'email'))->whereNot('name', 'Unlucky'), + expectCreatedAttribute: new Exists(rule: (new BaseExists('users', 'email'))->whereNot('name', 'Unlucky')) + ); + + yield fixature( + attribute: new Exists('users', 'id', where: new WhereNullConstraint('deleted_at')), + expected: (new BaseExists('users', 'id'))->whereNull('deleted_at'), + expectCreatedAttribute: new Exists(rule: (new BaseExists('users', 'id'))->whereNull('deleted_at')) + ); + + yield fixature( + attribute: new Exists('users', 'id', where: new WhereNotNullConstraint('email_verified_at')), + expected: (new BaseExists('users', 'id'))->whereNotNull('email_verified_at'), + expectCreatedAttribute: new Exists(rule: (new BaseExists('users', 'id'))->whereNotNull('email_verified_at')) + ); + + yield fixature( + attribute: new Exists('users', 'id', where: [ + new WhereConstraint('active', true), + $closure, + ]), + expected: (new BaseExists('users', 'id')) + ->where('active', true) + ->where($closure), + expectCreatedAttribute: new Exists(rule: (new BaseExists('users', 'id')) + ->where('active', true) + ->where($closure)) + ); + + yield fixature( + attribute: new Exists('users', 'id', where: []), + expected: new BaseExists('users', 'id'), + expectCreatedAttribute: new Exists(rule: new BaseExists('users', 'id')) + ); + + yield fixature( + attribute: new Exists('users', 'id', where: ['fake']), + expected: '', + exception: InvalidArgumentException::class, + ); +} + +function inAttributes(): Generator +{ + yield fixature( + attribute: new In('key'), + expected: new BaseIn(['key']), + expectCreatedAttribute: new In(new BaseIn(['key'])) + ); + + yield fixature( + attribute: new In(['key', 'other']), + expected: new BaseIn(['key', 'other']), + expectCreatedAttribute: new In(new BaseIn(['key', 'other'])) + ); + + yield fixature( + attribute: new In('key', 'other'), + expected: new BaseIn(['key', 'other']), + expectCreatedAttribute: new In(new BaseIn(['key', 'other'])) + ); +} + +function mimesAttributes(): Generator +{ + yield fixature( + attribute: new MimeTypes('video/quicktime'), + expected: 'mimetypes:video/quicktime', + ); + + yield fixature( + attribute: new MimeTypes(['video/quicktime', 'video/avi']), + expected: 'mimetypes:video/quicktime,video/avi', + ); + + yield fixature( + attribute: new MimeTypes('video/quicktime', 'video/avi'), + expected: 'mimetypes:video/quicktime,video/avi', + ); +} + +function mimeTypesAttributes(): Generator +{ + yield fixature( + attribute: new Mimes('jpg'), + expected: 'mimes:jpg', + ); + + yield fixature( + attribute: new Mimes(['jpg', 'png']), + expected: 'mimes:jpg,png', + ); + + yield fixature( + attribute: new Mimes('jpg', 'png'), + expected: 'mimes:jpg,png', + ); +} + +function notInAttributes(): Generator +{ + yield fixature( + attribute: new NotIn('key'), + expected: new BaseNotIn(['key']), + expectCreatedAttribute: new NotIn(new BaseNotIn(['key'])) + ); + + yield fixature( + attribute: new NotIn(['key', 'other']), + expected: new BaseNotIn(['key', 'other']), + expectCreatedAttribute: new NotIn(new BaseNotIn(['key', 'other'])) + ); + + yield fixature( + attribute: new NotIn('key', 'other'), + expected: new BaseNotIn(['key', 'other']), + expectCreatedAttribute: new NotIn(new BaseNotIn(['key', 'other'])) + ); +} + +function passwordAttributes(): Generator +{ + yield fixature( + attribute: new Password, + expected: new BasePassword(12), + expectCreatedAttribute: new Password(rule: new BasePassword(12)), + ); + + yield fixature( + attribute: new Password(min: 20), + expected: new BasePassword(20), + expectCreatedAttribute: new Password(rule: new BasePassword(20)), + ); + + yield fixature( + attribute: new Password(letters: true, mixedCase: true, numbers: true, uncompromised: true, uncompromisedThreshold: 12), + expected: (new BasePassword(12))->letters()->mixedCase()->numbers()->uncompromised(12), + expectCreatedAttribute: new Password(rule: (new BasePassword(12))->letters()->mixedCase()->numbers()->uncompromised(12)), + ); +} + +function prohibitedIfAttributes(): Generator +{ + yield fixature( + attribute: new ProhibitedIf('field', 'key'), + expected: 'prohibited_if:field,key', + ); + + yield fixature( + attribute: new ProhibitedIf('field', DummyBackedEnum::FOO), + expected: 'prohibited_if:field,foo', + expectCreatedAttribute: new ProhibitedIf('field', DummyBackedEnum::FOO->value), + ); + + yield fixature( + attribute: new ProhibitedIf('field', ['key', 'other']), + expected: 'prohibited_if:field,key,other', + ); + + yield fixature( + attribute: new ProhibitedIf('field', 'key', 'other'), + expected: 'prohibited_if:field,key,other', + ); +} + +function prohibitedUnlessAttributes(): Generator +{ + yield fixature( + attribute: new ProhibitedUnless('field', 'key'), + expected: 'prohibited_unless:field,key', + ); + + yield fixature( + attribute: new ProhibitedUnless('field', DummyBackedEnum::FOO), + expected: 'prohibited_unless:field,foo', + expectCreatedAttribute: new ProhibitedUnless('field', DummyBackedEnum::FOO->value), + ); + + yield fixature( + attribute: new ProhibitedUnless('field', ['key', 'other']), + expected: 'prohibited_unless:field,key,other', + ); + + yield fixature( + attribute: new ProhibitedUnless('field', 'key', 'other'), + expected: 'prohibited_unless:field,key,other', + ); +} + +function prohibitsAttributes(): Generator +{ + yield fixature( + attribute: new Prohibits('key'), + expected: 'prohibits:key', + ); + + yield fixature( + attribute: new Prohibits(['key', 'other']), + expected: 'prohibits:key,other', + ); + + yield fixature( + attribute: new Prohibits('key', 'other'), + expected: 'prohibits:key,other', + ); +} + +function requiredArrayKeysAttributes(): Generator +{ + yield fixature( + attribute: new RequiredArrayKeys('x'), + expected: 'required_array_keys:x', + ); + + yield fixature( + attribute: new RequiredArrayKeys(['x', 'y']), + expected: 'required_array_keys:x,y', + ); + + yield fixature( + attribute: new RequiredArrayKeys('x', 'y'), + expected: 'required_array_keys:x,y', + ); +} + +function requiredIfAttributes(): Generator +{ + yield fixature( + attribute: new RequiredIf('field'), + expected: 'required_if:field', + ); + + yield fixature( + attribute: new RequiredIf('field', 'key'), + expected: 'required_if:field,key', + ); + + yield fixature( + attribute: new RequiredIf('field', DummyBackedEnum::FOO), + expected: 'required_if:field,foo', + expectCreatedAttribute: new RequiredIf('field', DummyBackedEnum::FOO->value), + ); + + yield fixature( + attribute: new RequiredIf('field', ['key', 'other']), + expected: 'required_if:field,key,other', + ); + + yield fixature( + attribute: new RequiredIf('field', 'key', 'other'), + expected: 'required_if:field,key,other', + ); +} + +function requiredUnlessAttributes(): Generator +{ + yield fixature( + attribute: new RequiredUnless('field', 'key'), + expected: 'required_unless:field,key', + ); + + yield fixature( + attribute: new RequiredUnless('field', DummyBackedEnum::FOO), + expected: 'required_unless:field,foo', + expectCreatedAttribute: new RequiredUnless('field', DummyBackedEnum::FOO->value), + ); + + yield fixature( + attribute: new RequiredUnless('field', ['key', 'other']), + expected: 'required_unless:field,key,other', + ); + + yield fixature( + attribute: new RequiredUnless('field', 'key', 'other'), + expected: 'required_unless:field,key,other', + ); + yield fixature( + attribute: new RequiredUnless('key', 'null'), + expected: 'required_unless:key,null', + ); +} + +function requiredWithAttributes(): Generator +{ + yield fixature( + attribute: new RequiredWith('key'), + expected: 'required_with:key', + ); + + yield fixature( + attribute: new RequiredWith(['key', 'other']), + expected: 'required_with:key,other', + ); + + yield fixature( + attribute: new RequiredWith('key', 'other'), + expected: 'required_with:key,other', + ); +} + +function requiredWithAllAttributes(): Generator +{ + yield fixature( + attribute: new RequiredWithAll('key'), + expected: 'required_with_all:key', + ); + + yield fixature( + attribute: new RequiredWithAll(['key', 'other']), + expected: 'required_with_all:key,other', + ); + + yield fixature( + attribute: new RequiredWithAll('key', 'other'), + expected: 'required_with_all:key,other', + ); +} + +function requiredWithoutAttributes(): Generator +{ + yield fixature( + attribute: new RequiredWithout('key'), + expected: 'required_without:key', + ); + + yield fixature( + attribute: new RequiredWithout(['key', 'other']), + expected: 'required_without:key,other', + ); + + yield fixature( + attribute: new RequiredWithout('key', 'other'), + expected: 'required_without:key,other', + ); +} + +function requiredWithoutAllAttributes(): Generator +{ + yield fixature( + attribute: new RequiredWithoutAll('key'), + expected: 'required_without_all:key', + ); + + yield fixature( + attribute: new RequiredWithoutAll(['key', 'other']), + expected: 'required_without_all:key,other', + ); + + yield fixature( + attribute: new RequiredWithoutAll('key', 'other'), + expected: 'required_without_all:key,other', + ); +} + +function startsWithAttributes(): Generator +{ + yield fixature( + attribute: new StartsWith('x'), + expected: 'starts_with:x', + ); + + yield fixature( + attribute: new StartsWith(['x', 'y']), + expected: 'starts_with:x,y', + ); + + yield fixature( + attribute: new StartsWith('x', 'y'), + expected: 'starts_with:x,y', + ); +} + +function uniqueAttributes(): Generator +{ + yield fixature( + attribute: new Unique('users'), + expected: new BaseUnique('users'), + expectCreatedAttribute: new Unique(rule: new BaseUnique('users')) + ); + + yield fixature( + attribute: new Unique('users', 'email'), + expected: new BaseUnique('users', 'email'), + expectCreatedAttribute: new Unique(rule: new BaseUnique('users', 'email')) + ); + + yield fixature( + attribute: new Unique('users', 'email', connection: 'tenant'), + expected: new BaseUnique('tenant.users', 'email'), + expectCreatedAttribute: new Unique(rule: new BaseUnique('tenant.users', 'email')) + ); + + yield fixature( + attribute: new Unique('users', 'email', withoutTrashed: true), + expected: (new BaseUnique('users', 'email'))->withoutTrashed(), + expectCreatedAttribute: new Unique(rule: (new BaseUnique('users', 'email'))->withoutTrashed()) + ); + + yield fixature( + attribute: new Unique('users', 'email', withoutTrashed: true, deletedAtColumn: 'deleted_when'), + expected: (new BaseUnique('users', 'email'))->withoutTrashed('deleted_when'), + expectCreatedAttribute: new Unique(rule: (new BaseUnique('users', 'email'))->withoutTrashed('deleted_when')) + ); + + yield fixature( + attribute: new Unique('users', 'email', ignore: 5), + expected: (new BaseUnique('users', 'email'))->ignore(5), + expectCreatedAttribute: new Unique(rule: (new BaseUnique('users', 'email'))->ignore(5)) + ); + + yield fixature( + attribute: new Unique('users', 'email', ignore: 5, ignoreColumn: 'uuid'), + expected: (new BaseUnique('users', 'email'))->ignore(5, 'uuid'), + expectCreatedAttribute: new Unique(rule: (new BaseUnique('users', 'email'))->ignore(5, 'uuid')) + ); + + $closure = fn (Builder $builder): Builder => $builder; + + yield fixature( + attribute: new Unique('users', 'email', where: $closure), + expected: (new BaseUnique('users', 'email'))->where($closure), + expectCreatedAttribute: new Unique(rule: (new BaseUnique('users', 'email'))->where($closure)) + ); + + yield fixature( + attribute: new Unique('users', 'email', where: new WhereConstraint('active', true)), + expected: (new BaseUnique('users', 'email'))->where('active', true), + expectCreatedAttribute: new Unique(rule: (new BaseUnique('users', 'email'))->where('active', true)) + ); + + yield fixature( + attribute: new Unique('users', 'email', where: new WhereNotConstraint('name', 'Unlucky')), + expected: (new BaseUnique('users', 'email'))->whereNot('name', 'Unlucky'), + expectCreatedAttribute: new Unique(rule: (new BaseUnique('users', 'email'))->whereNot('name', 'Unlucky')) + ); + + yield fixature( + attribute: new Unique('users', 'email', where: [ + new WhereConstraint('active', true), + new WhereNullConstraint('deleted_at'), + ]), + expected: (new BaseUnique('users', 'email'))->where('active', true)->whereNull('deleted_at'), + expectCreatedAttribute: new Unique(rule: (new BaseUnique('users', 'email'))->where('active', true)->whereNull('deleted_at')) + ); + + yield fixature( + attribute: new Unique('users', 'email', where: new WhereNotNullConstraint('email_verified_at')), + expected: (new BaseUnique('users', 'email'))->whereNotNull('email_verified_at'), + expectCreatedAttribute: new Unique(rule: (new BaseUnique('users', 'email'))->whereNotNull('email_verified_at')) + ); + + yield fixature( + attribute: new Unique('users', 'email', where: [ + new WhereConstraint('active', true), + $closure, + ]), + expected: (new BaseUnique('users', 'email')) + ->where('active', true) + ->where($closure), + expectCreatedAttribute: new Unique(rule: (new BaseUnique('users', 'email')) + ->where('active', true) + ->where($closure)) + ); + + yield fixature( + attribute: new Unique('users', 'email', where: []), + expected: new BaseUnique('users', 'email'), + expectCreatedAttribute: new Unique(rule: new BaseUnique('users', 'email')) + ); + + yield fixature( + attribute: new Unique('users', 'email', where: ['fake']), + expected: '', + exception: InvalidArgumentException::class, + ); +} diff --git a/tests/Data/Fixtures/SimpleChildDataWithMappedOutputName.php b/tests/Data/Fixtures/SimpleChildDataWithMappedOutputName.php new file mode 100644 index 0000000000..4b4dd11ace --- /dev/null +++ b/tests/Data/Fixtures/SimpleChildDataWithMappedOutputName.php @@ -0,0 +1,29 @@ + ['string', 'required', 'min:10', 'max:100'], + ]; + } +} diff --git a/tests/Data/Fixtures/SimpleDataWithPropertyHooks.php b/tests/Data/Fixtures/SimpleDataWithPropertyHooks.php new file mode 100644 index 0000000000..2c42af6db4 --- /dev/null +++ b/tests/Data/Fixtures/SimpleDataWithPropertyHooks.php @@ -0,0 +1,30 @@ + 'virtual'; + } + + public string $backed = 'default' { + get => strtoupper($this->backed); + } + + public string $backedConstructorProperty { + get => strtoupper($this->constructorProperty); + } + + /** + * Create a fixture with virtual and backed property hooks. + */ + public function __construct( + protected string $constructorProperty = 'default' + ) { + } +} diff --git a/tests/Data/Fixtures/SimpleDataWithWrap.php b/tests/Data/Fixtures/SimpleDataWithWrap.php new file mode 100644 index 0000000000..69372cb39d --- /dev/null +++ b/tests/Data/Fixtures/SimpleDataWithWrap.php @@ -0,0 +1,34 @@ +bind(self::class, fn (): self => new self($value)); + } +} diff --git a/tests/Data/Fixtures/Transformers/ConfidentialDataCollectionTransformer.php b/tests/Data/Fixtures/Transformers/ConfidentialDataCollectionTransformer.php new file mode 100644 index 0000000000..9472103e19 --- /dev/null +++ b/tests/Data/Fixtures/Transformers/ConfidentialDataCollectionTransformer.php @@ -0,0 +1,22 @@ + (new ConfidentialDataTransformer)->transform($property, $data, $context), $value); + } +} diff --git a/tests/Data/Fixtures/Transformers/ConfidentialDataTransformer.php b/tests/Data/Fixtures/Transformers/ConfidentialDataTransformer.php new file mode 100644 index 0000000000..6225f0caac --- /dev/null +++ b/tests/Data/Fixtures/Transformers/ConfidentialDataTransformer.php @@ -0,0 +1,24 @@ +toArray())->map(fn (mixed $value): string => 'CONFIDENTIAL')->toArray(); + } +} diff --git a/tests/Data/Fixtures/Transformers/StringToUpperTransformer.php b/tests/Data/Fixtures/Transformers/StringToUpperTransformer.php new file mode 100644 index 0000000000..de0a774246 --- /dev/null +++ b/tests/Data/Fixtures/Transformers/StringToUpperTransformer.php @@ -0,0 +1,20 @@ +|object|string + */ + public function getRules(ValidationPath $path): array|object|string + { + return $this->rules; + } +} diff --git a/tests/Data/Http/RequestQueryStringPartialsResolverTest.php b/tests/Data/Http/RequestQueryStringPartialsResolverTest.php index 30a6147dd4..c1d71ed933 100644 --- a/tests/Data/Http/RequestQueryStringPartialsResolverTest.php +++ b/tests/Data/Http/RequestQueryStringPartialsResolverTest.php @@ -121,6 +121,22 @@ public function testInvalidNestedChildFallsBackToTheAllowedParent(): void ], $response->getData(true)); } + public function testNumericPathSegmentsAreTreatedAsUnknownNames(): void + { + $response = $this->makeData()->toResponse($this->request([ + 'only' => 'display_name,0,nested.0', + ])); + + $this->assertSame([ + 'display_name' => 'Taylor', + 'nested' => [ + 'id' => 2, + 'display_name' => 'Abigail', + 'default_secret' => 'nested-default-secret', + ], + ], $response->getData(true)); + } + public function testNullAllowlistPermitsWildcardSelection(): void { $data = new UnrestrictedRequestPartialData( diff --git a/tests/Data/Http/ResourceResponseTest.php b/tests/Data/Http/ResourceResponseTest.php index 6b0cbad1eb..3742217049 100644 --- a/tests/Data/Http/ResourceResponseTest.php +++ b/tests/Data/Http/ResourceResponseTest.php @@ -5,6 +5,7 @@ namespace Hypervel\Tests\Data\Http\ResourceResponseTest; use Hypervel\Contracts\Foundation\Application; +use Hypervel\Data\Attributes\MapOutputName; use Hypervel\Data\Concerns\BaseData as BaseDataConcern; use Hypervel\Data\Concerns\IncludeableData as IncludeableDataConcern; use Hypervel\Data\Concerns\TransformableData as TransformableDataConcern; @@ -21,9 +22,12 @@ use Hypervel\Data\Lazy; use Hypervel\Data\PaginatedDataCollection; use Hypervel\Data\Resource; +use Hypervel\Data\Support\Transformation\DataTransformer; use Hypervel\Http\JsonResponse; use Hypervel\Http\Request; +use Hypervel\Pagination\Cursor; use Hypervel\Pagination\CursorPaginator; +use Hypervel\Pagination\LengthAwarePaginator; use Hypervel\Pagination\Paginator; use Hypervel\Support\Collection; use Hypervel\Support\LazyCollection; @@ -98,6 +102,16 @@ public function testJsonOptionsAndResponseHookAreDelegatedWithoutChangingTheOrig $this->assertSame($data, $response->getOriginalContent()); } + public function testCollectionResponseHookIsDelegatedWithoutChangingTheOriginal(): void + { + $collection = new CreatedResponseCollection(ResponseData::class, [['id' => 1, 'name' => 'Taylor']]); + $response = $collection->toResponse(Request::create('/', 'POST')); + + $this->assertSame(201, $response->getStatusCode()); + $this->assertSame([['id' => 1, 'name' => 'Taylor']], $response->getData(true)); + $this->assertSame($collection[0], $response->getOriginalContent()[0]); + } + public function testAllowedRequestIncludesAreAppliedToResponseTransformation(): void { $excluded = new LazyResponseData(1, Lazy::create(static fn (): string => 'secret')); @@ -210,14 +224,54 @@ public function testPaginatorResponsesPreserveMetadataAndOriginalDtoItems(): voi ); } + public function testPaginatedResponsesMatchTheirJsonOutput(): void + { + $paginated = (new PaginatedDataCollection( + ResponseData::class, + new LengthAwarePaginator( + [['id' => 1, 'name' => 'Taylor']], + 3, + 1, + 2, + ['path' => '/items'], + ), + ))->wrap('items'); + // The output renames the cursor's ordering field, so the cursor must come from the original items. + $cursorPaginated = new CursorPaginatedDataCollection( + MappedCursorResponseData::class, + new CursorPaginator( + [['id' => 2, 'name' => 'Abigail'], ['id' => 3, 'name' => 'Ada']], + 1, + null, + ['path' => '/cursor-items', 'parameters' => ['id']], + ), + ); + $cursorItem = $cursorPaginated->items()->getCollection()[0]; + + $paginatedResponse = $paginated->toResponse(Request::create('/items')); + $cursorResponse = $cursorPaginated->toResponse(Request::create('/cursor-items')); + $cursorBody = $cursorResponse->getData(true); + + $this->assertSame(['items', 'links', 'meta'], array_keys($paginatedResponse->getData(true))); + $this->assertJsonStringEqualsJsonString($paginated->toJson(), $paginatedResponse->getContent()); + $this->assertSame(['data', 'links', 'meta'], array_keys($cursorBody)); + $this->assertSame([['identifier' => 2, 'name' => 'Abigail']], $cursorBody['data']); + $this->assertSame(2, Cursor::fromEncoded($cursorBody['meta']['next_cursor'])->parameter('id')); + $this->assertJsonStringEqualsJsonString($cursorPaginated->toJson(), $cursorResponse->getContent()); + $this->assertSame($cursorItem, $cursorPaginated->items()->getCollection()[0]); + $this->assertSame($cursorItem, $cursorResponse->getOriginalContent()[0]); + } + public function testCollectionJsonOptionsUseTheDeclaredItemClassWithoutInstantiation(): void { + $transformer = $this->app->make(DataTransformer::class); $collection = new DataCollection(AbstractJsonOptionsData::class, []); $resource = new DataCollectionResource( $collection, new Collection, [], null, + $transformer, ); $dtoCollection = new DataCollection(ConstructorRequiredDto::class, []); $dtoResource = new DataCollectionResource( @@ -225,6 +279,7 @@ public function testCollectionJsonOptionsUseTheDeclaredItemClassWithoutInstantia new Collection, [], null, + $transformer, ); $this->assertSame(JSON_UNESCAPED_SLASHES, $resource->jsonOptions()); @@ -318,6 +373,19 @@ public function __construct( } } +class MappedCursorResponseData extends Data +{ + /** + * Create a response item whose cursor field is renamed in the output. + */ + public function __construct( + #[MapOutputName('identifier')] + public int $id, + public string $name, + ) { + } +} + class ResponseResource extends Resource { public function __construct( @@ -365,6 +433,17 @@ public function withResponse(Request $request, JsonResponse $response): void } } +class CreatedResponseCollection extends DataCollection +{ + /** + * Customize the outgoing resource response. + */ + public function withResponse(Request $request, JsonResponse $response): void + { + $response->setStatusCode(201); + } +} + class LazyResponseData extends Data { public function __construct( diff --git a/tests/Data/Inertia/InertiaIntegrationTest.php b/tests/Data/Inertia/InertiaIntegrationTest.php index d2296ae392..03bfd45834 100644 --- a/tests/Data/Inertia/InertiaIntegrationTest.php +++ b/tests/Data/Inertia/InertiaIntegrationTest.php @@ -7,13 +7,17 @@ use Hypervel\Contracts\Foundation\Application; use Hypervel\Data\Attributes\AutoInertiaDeferred; use Hypervel\Data\Attributes\AutoInertiaLazy; +use Hypervel\Data\CursorPaginatedDataCollection; use Hypervel\Data\Data; use Hypervel\Data\DataServiceProvider; use Hypervel\Data\Lazy; +use Hypervel\Data\PaginatedDataCollection; use Hypervel\Http\JsonResponse; use Hypervel\Http\Request; use Hypervel\Inertia\Inertia; use Hypervel\Inertia\InertiaServiceProvider; +use Hypervel\Pagination\CursorPaginator; +use Hypervel\Pagination\LengthAwarePaginator; use Hypervel\Testbench\TestCase; use function Hypervel\Coroutine\parallel; @@ -93,6 +97,41 @@ function (): int { $this->assertSame(21, $second); } + public function testScrollPropsReadPaginatedDataCollections(): void + { + $users = new PaginatedDataCollection( + InertiaChildData::class, + new LengthAwarePaginator([['id' => '1']], 3, 1, 2, ['path' => '/users']), + ); + $events = (new CursorPaginatedDataCollection( + InertiaChildData::class, + new CursorPaginator([['id' => '2'], ['id' => '3']], 1, null, ['path' => '/events']), + ))->wrap('items'); + + $response = Inertia::render('TestComponent', [ + 'users' => Inertia::scroll($users), + 'events' => Inertia::scroll($events, 'items'), + ])->toResponse($this->makeInertiaRequest()); + + $this->assertInstanceOf(JsonResponse::class, $response); + + $page = $response->getData(true); + + $this->assertSame([['id' => 1]], $page['props']['users']['data']); + $this->assertSame(2, $page['props']['users']['meta']['current_page']); + $this->assertSame([['id' => 2]], $page['props']['events']['items']); + $this->assertSame([ + 'pageName' => 'page', + 'previousPage' => 1, + 'nextPage' => 3, + 'currentPage' => 2, + 'reset' => false, + ], $page['scrollProps']['users']); + $this->assertSame('cursor', $page['scrollProps']['events']['pageName']); + $this->assertSame($events->items()->nextCursor()?->encode(), $page['scrollProps']['events']['nextPage']); + $this->assertSame(['users.data', 'events.items'], $page['mergeProps']); + } + /** * Resolve Data through an Inertia response. * diff --git a/tests/Data/InjectPropertyValuesTest.php b/tests/Data/InjectPropertyValuesTest.php new file mode 100644 index 0000000000..d2e26e8618 --- /dev/null +++ b/tests/Data/InjectPropertyValuesTest.php @@ -0,0 +1,122 @@ + 'Hello World', + 'array' => ['a', 'b'], + 'data' => new SimpleData('Hello World'), + ]; + + $dataClass = new class('', [], new SimpleData('')) extends Data { + /** + * Create the data object from injected values. + */ + public function __construct( + #[FromTestPayload('string')] + public string $string, + #[FromTestPayload('array')] + public array $array, + #[FromTestPayload('data')] + public SimpleData $data, + ) { + } + }; + + $data = $dataClass::from(); + + $this->assertSame('Hello World', $data->string); + $this->assertSame(['a', 'b'], $data->array); + $this->assertInstanceOf(SimpleData::class, $data->data); + $this->assertSame('Hello World', $data->data->string); + } + + public function testReplacesPropertiesWhenInjectedParameterPropertiesExist(): void + { + FromTestPayload::$payload = [ + 'foo' => 'Rick', + 'user_id' => 2, + ]; + + $dataClass = new class('', 0) extends Data { + /** + * Create the data object from injected values. + */ + public function __construct( + #[FromTestPayload('foo')] + public string $name, + #[FromTestPayload('user_id'), MapInputName('user_id')] + public int $userId, + ) { + } + }; + + $data = $dataClass::from([ + 'name' => 'Jon', + 'user_id' => 1, + ]); + + $this->assertSame('Rick', $data->name); + $this->assertSame(2, $data->userId); + } +} + +#[Attribute(Attribute::TARGET_PARAMETER)] +class FromTestPayload implements ContextualAttribute +{ + public static array $payload = []; + + /** + * Create an attribute reading one value from the test payload. + */ + public function __construct( + public string $parameter, + ) { + } + + /** + * Resolve the value from the test payload. + */ + public static function resolve(self $attribute, Container $container): mixed + { + return static::$payload[$attribute->parameter] ?? null; + } +} diff --git a/tests/Data/LazyTest.php b/tests/Data/LazyTest.php index bb26c660f5..8836ed36c4 100644 --- a/tests/Data/LazyTest.php +++ b/tests/Data/LazyTest.php @@ -12,7 +12,6 @@ use Hypervel\Data\Support\Lazy\RelationalLazy; use Hypervel\Database\Eloquent\Model; use Hypervel\Inertia\DeferProp; -use Hypervel\Inertia\OptionalProp; use Hypervel\Testbench\TestCase; class LazyTest extends TestCase @@ -59,16 +58,10 @@ public function testItExposesLazyClosuresWithoutInvokingThem(): void $this->assertSame(1, $calls); } - public function testItCreatesInertiaLazyAndDeferredProperties(): void + public function testItCreatesInertiaDeferredProperties(): void { - $lazy = Lazy::inertia(static fn (): string => 'lazy'); $deferred = Lazy::inertiaDeferred('deferred', 'analytics', true); - $this->assertTrue($lazy->shouldBeIncluded()); - $this->assertFalse($lazy->resolvesToData()); - $this->assertInstanceOf(OptionalProp::class, $lazy->resolve()); - $this->assertSame('lazy', ($lazy->resolve())()); - $prop = $deferred->resolve(); $this->assertTrue($deferred->shouldBeIncluded()); diff --git a/tests/Data/MagicalCreationTest.php b/tests/Data/MagicalCreationTest.php new file mode 100644 index 0000000000..658adb29aa --- /dev/null +++ b/tests/Data/MagicalCreationTest.php @@ -0,0 +1,529 @@ +artist); + } + + /** + * Create the data object from an array payload. + */ + public static function fromArray(array $payload): static + { + return new self($payload['string']); + } + }; + + $this->assertEquals(new $data('Hello World'), $data::from('Hello World')); + $this->assertEquals(new $data('Rick Astley'), $data::from(DummyDto::rick())); + $this->assertEquals(new $data('Bon Jovi'), $data::from(DummyDto::bon())); + $this->assertEquals(new $data('Hello World'), $data::from(['string' => 'Hello World'])); + $this->assertEquals(new $data('Hello World'), $data::from(DummyModelWithCasts::make(['string' => 'Hello World']))); + } + + public function testCanMagicallyCreateADataObject(): void + { + $dataClass = new class('', '') extends Data { + /** + * Create the data object from two values. + */ + public function __construct( + public mixed $propertyA, + public mixed $propertyB, + ) { + } + + /** + * Create the data object from strings with a default second value. + */ + public static function fromStringWithDefault(string $a, string $b = 'World'): static + { + return new self($a, $b); + } + + /** + * Create the data object from two integers. + */ + public static function fromIntsWithDefault(int $a, int $b): static + { + return new self($a, $b); + } + + /** + * Create the data object from a simple data object. + */ + public static function fromSimpleData(SimpleData $data): static + { + return new self($data->string, $data->string); + } + + /** + * Create the data object from any data object. + */ + public static function fromData(Data $data): static + { + return new self('data', json_encode($data)); + } + }; + + $this->assertEquals(new $dataClass('Hello', 'World'), $dataClass::from('Hello')); + $this->assertEquals(new $dataClass('Hello', 'World'), $dataClass::from('Hello', 'World')); + $this->assertEquals(new $dataClass(42, 69), $dataClass::from(42, 69)); + $this->assertEquals(new $dataClass('Hello', 'Hello'), $dataClass::from(SimpleData::from('Hello'))); + $this->assertEquals(new $dataClass('data', '{"enum":"foo"}'), $dataClass::from(new EnumData(DummyBackedEnum::FOO))); + } + + public function testCanCreateDataUsingAMagicalMethodWithTheInterfaceOfTheValueAsType(): void + { + $data = new class('') extends Data { + /** + * Create the data object from its string. + */ + public function __construct(public string $string) + { + } + + /** + * Create the data object from an arrayable value. + */ + public static function fromInterface(Arrayable $arrayable): static + { + return new self($arrayable->toArray()['string']); + } + }; + + $interfaceable = new class implements Arrayable { + /** + * Get the value as an array. + */ + public function toArray(): array + { + return [ + 'string' => 'Rick Astley', + ]; + } + }; + + $this->assertEquals(new $data('Rick Astley'), $data::from($interfaceable)); + } + + public function testCanCreateDataUsingAMagicalMethodWithTheBaseClassOfTheValueAsType(): void + { + $data = new class('') extends Data { + /** + * Create the data object from its string. + */ + public function __construct(public string $string) + { + } + + /** + * Create the data object from any model. + */ + public static function fromModel(Model $model): static + { + return new self($model->string); + } + }; + + $inherited = new DummyModel(['string' => 'Rick Astley']); + + $this->assertEquals(new $data('Rick Astley'), $data::from($inherited)); + } + + public function testCanCreateDataFromAMagicalMethodWithMultipleParameters(): void + { + $this->assertEquals( + new DataWithMultipleArgumentCreationMethod('Rick Astley_42'), + DataWithMultipleArgumentCreationMethod::from('Rick Astley', 42), + ); + } + + public function testCanInjectTheCreationContextWhenUsingAMagicalMethod(): void + { + $dataClass = new class extends Data { + /** + * Create the data object from its string. + */ + public function __construct( + public string $string = 'something' + ) { + } + + /** + * Create the data object from a prefix and the creation context. + */ + public static function fromArray(string $prefix, CreationContext $context): static + { + return new self("{$prefix} {$context->dataClass}"); + } + }; + + $this->assertSame('Hi there ' . $dataClass::class, $dataClass::from('Hi there')->string); + } + + public function testWillUseMagicMethodsWhenCreatingACollectionOfDataObjects(): void + { + $dataClass = new class('') extends Data { + /** + * Create the data object from its string. + */ + public function __construct(public string $otherString) + { + } + + /** + * Create the data object from a simple data object. + */ + public static function fromSimpleData(SimpleData $simpleData): static + { + return new self($simpleData->string); + } + }; + + $collection = new DataCollection($dataClass::class, [ + SimpleData::from('A'), + SimpleData::from('B'), + ]); + + $this->assertInstanceOf($dataClass::class, $collection[0]); + $this->assertEquals('A', $collection[0]->otherString); + $this->assertInstanceOf($dataClass::class, $collection[1]); + $this->assertEquals('B', $collection[1]->otherString); + } + + public function testCanMagicallyCollectData(): void + { + $dataClass = new class extends Data { + public string $string; + + /** + * Create the data object from a string. + */ + public static function fromString(string $string): self + { + $data = new self; + + $data->string = $string; + + return $data; + } + + /** + * Collect an array into a custom collection. + */ + public static function collectArray(array $items): TestSomeCustomCollection + { + return new TestSomeCustomCollection($items); + } + + /** + * Collect a collection into an array. + */ + public static function collectCollection(Collection $items): array + { + return $items->all(); + } + + /** + * Collect a length-aware paginator into a cursor paginator. + */ + public static function collectPaginator(LengthAwarePaginator $items): CursorPaginator + { + return new CursorPaginator($items->all(), $items->perPage()); + } + + /** + * Collect a cursor paginator into a length-aware paginator. + */ + public static function collectCursorPaginator(CursorPaginator $items): LengthAwarePaginator + { + return new LengthAwarePaginator($items->all(), $items->count(), $items->perPage()); + } + }; + + $collected = $dataClass::collect(['a', 'b', 'c']); + + $this->assertInstanceOf(TestSomeCustomCollection::class, $collected); + $this->assertEquals([ + $dataClass::from('a'), + $dataClass::from('b'), + $dataClass::from('c'), + ], $collected->all()); + + $this->assertEquals([ + $dataClass::from('a'), + $dataClass::from('b'), + $dataClass::from('c'), + ], $dataClass::collect(new Collection(['a', 'b', 'c']))); + + $this->assertEquals([ + $dataClass::from('a'), + $dataClass::from('b'), + $dataClass::from('c'), + ], $dataClass::collect(new TestSomeCustomCollection(['a', 'b', 'c']))); + + $this->assertInstanceOf(CursorPaginator::class, $dataClass::collect(new LengthAwarePaginator(['a', 'b', 'c'], 3, 15))); + $this->assertInstanceOf(LengthAwarePaginator::class, $dataClass::collect(new CursorPaginator(['a', 'b', 'c'], 15))); + } + + public function testCanDisableMagicallyCollectingData(): void + { + $dataClass = new class('') extends SimpleData { + /** + * Collect an array into a collection. + */ + public static function collectArray(array $items): Collection + { + return new Collection($items); + } + }; + + $this->assertEquals([ + new $dataClass('a'), + new $dataClass('b'), + new $dataClass('c'), + ], $dataClass::factory()->withoutMagicalCreation()->collect([ + ['string' => 'a'], + ['string' => 'b'], + ['string' => 'c'], + ])); + } + + public function testCanDisableSpecificMagicCollectingDataMethods(): void + { + $dataClass = new class('') extends SimpleData { + /** + * Collect an array into a collection. + */ + public static function collectArray(array $items): Collection + { + return new Collection($items); + } + }; + + $collected = $dataClass::collect(['a', 'b', 'c']); + + $this->assertInstanceOf(Collection::class, $collected); + $this->assertEquals([ + new $dataClass('a'), + new $dataClass('b'), + new $dataClass('c'), + ], $collected->all()); + + $this->assertEquals([ + new $dataClass('a'), + new $dataClass('b'), + new $dataClass('c'), + ], $dataClass::factory()->ignoreMagicalMethod('collectArray')->collect([ + ['string' => 'a'], + ['string' => 'b'], + ['string' => 'c'], + ])); + } + + public function testCanInjectTheCreationContextWhenCollectingDataWithAMagicalMethod(): void + { + $dataClass = new class('') extends SimpleData { + /** + * Collect an array, appending the creation context's class to each string. + */ + public static function collectArray(array $items, CreationContext $context): array + { + return array_map(fn (SimpleData $data): SimpleData => new SimpleData($data->string . ' ' . $context->dataClass), $items); + } + }; + + $this->assertEquals([ + SimpleData::from('a ' . $dataClass::class), + SimpleData::from('b ' . $dataClass::class), + SimpleData::from('c ' . $dataClass::class), + ], $dataClass::collect(['a', 'b', 'c'])); + } + + #[DataProvider('collectIntoTargets')] + public function testCanUseAStringToCollectDataInto(string $into, Closure $expected): void + { + $this->assertEquals($expected(), SimpleData::collect(['A', 'B'], $into)); + } + + /** + * Get the collection targets and the values collecting into them produces. + */ + public static function collectIntoTargets(): iterable + { + yield 'array' => [ + 'array', + fn (): array => [ + SimpleData::from('A'), + SimpleData::from('B'), + ], + ]; + + yield 'hypervel collection' => [ + Collection::class, + fn (): Collection => new Collection([ + SimpleData::from('A'), + SimpleData::from('B'), + ]), + ]; + + yield 'hypervel lazy collection' => [ + LazyCollection::class, + fn (): LazyCollection => new LazyCollection([ + SimpleData::from('A'), + SimpleData::from('B'), + ]), + ]; + + yield 'data collection' => [ + DataCollection::class, + fn (): DataCollection => new DataCollection(SimpleData::class, [ + SimpleData::from('A'), + SimpleData::from('B'), + ]), + ]; + + yield 'custom data collection' => [ + CustomDataCollection::class, + fn (): CustomDataCollection => new CustomDataCollection(SimpleData::class, [ + SimpleData::from('A'), + SimpleData::from('B'), + ]), + ]; + } + + #[DataProvider('paginatorTargets')] + public function testCannotCollectAnArrayIntoAPaginatorTarget(string $into): void + { + // Upstream makes up a first page with a total of the item count and 15 per page. A paginator + // target here must come from a paginator source so its real pagination details are kept. + $this->expectException(CannotCreateDataCollectable::class); + $this->expectExceptionMessageIsOrContains('from `array`'); + + SimpleData::collect(['A', 'B'], $into); + } + + /** + * Get the paginator targets an array cannot be collected into. + */ + public static function paginatorTargets(): iterable + { + yield 'data paginated collection' => [PaginatedDataCollection::class]; + yield 'data cursor paginated collection' => [CursorPaginatedDataCollection::class]; + yield 'paginator' => [LengthAwarePaginator::class]; + yield 'cursor paginator' => [CursorPaginator::class]; + } + + public function testCanSpecificallySelectTheCorrectCollectMethodUsingAnIntoReturnType(): void + { + $dataClass = new class('') extends SimpleData { + /** + * Collect an array, uppercasing each string. + */ + public static function collectArray(array $items): array + { + return array_map( + fn (SimpleData $data): SimpleData => new SimpleData(strtoupper($data->string)), + $items + ); + } + + /** + * Collect an array into a collection, lowercasing each string. + */ + public static function collectCollection(array $items): Collection + { + return new Collection(array_map( + fn (SimpleData $data): SimpleData => new SimpleData(strtolower($data->string)), + $items + )); + } + }; + + $this->assertEquals([SimpleData::from('HELLO'), SimpleData::from('WORLD')], $dataClass::collect(['Hello', 'World'], 'array')); + + $collected = $dataClass::collect(['Hello', 'World'], Collection::class); + + $this->assertInstanceOf(Collection::class, $collected); + $this->assertEquals([SimpleData::from('hello'), SimpleData::from('world')], $collected->all()); + } + + public function testCanOnlyCollectArraysCollectionsPaginators(): void + { + $storage = new SplObjectStorage; + + // Any Traversable can be read for its items, but without an explicit target the source's own + // shape must be rebuildable. + $this->expectException(CannotCreateDataCollectable::class); + $this->expectExceptionMessageIsOrContains('from `SplObjectStorage`'); + + SimpleData::collect($storage); + } +} + +class TestSomeCustomCollection extends Collection +{ +} diff --git a/tests/Data/MappingTest.php b/tests/Data/MappingTest.php new file mode 100644 index 0000000000..29001ad791 --- /dev/null +++ b/tests/Data/MappingTest.php @@ -0,0 +1,386 @@ + 'never'], + ['description' => 'gonna'], + ['description' => 'give'], + ['description' => 'you'], + ['description' => 'up'], + ]); + + $dataClass = new class('hello', $data, $data, $dataCollection, $dataCollection) extends Data { + /** + * Create the data object with renamed and nested values. + */ + public function __construct( + #[MapOutputName('property')] + public string $string, + public SimpleDataWithMappedProperty $nested, + #[MapOutputName('nested_other')] + public SimpleDataWithMappedProperty $nested_renamed, + #[DataCollectionOf(SimpleDataWithMappedProperty::class)] + public array $nested_collection, + #[MapOutputName('nested_other_collection'), DataCollectionOf(SimpleDataWithMappedProperty::class)] + public array $nested_renamed_collection, + ) { + } + }; + + $collectionOutput = [ + ['description' => 'never'], + ['description' => 'gonna'], + ['description' => 'give'], + ['description' => 'you'], + ['description' => 'up'], + ]; + + $this->assertSame([ + 'property' => 'hello', + 'nested' => [ + 'description' => 'hello', + ], + 'nested_other' => [ + 'description' => 'hello', + ], + 'nested_collection' => $collectionOutput, + 'nested_other_collection' => $collectionOutput, + ], $dataClass->toArray()); + } + + public function testCanMapThePropertyNamesForTheWholeClassUsingOneAttributeWhenTransforming(): void + { + $data = DataWithMapper::from([ + 'cased_property' => 'We are the knights who say, ni!', + 'data_cased_property' => ['string' => 'Bring us a, shrubbery!'], + 'data_collection_cased_property' => [ + ['string' => 'One that looks nice!'], + ['string' => 'But not too expensive!'], + ], + ]); + + $this->assertSame([ + 'cased_property' => 'We are the knights who say, ni!', + 'data_cased_property' => ['string' => 'Bring us a, shrubbery!'], + 'data_collection_cased_property' => [ + ['string' => 'One that looks nice!'], + ['string' => 'But not too expensive!'], + ], + ], $data->toArray()); + } + + public function testCanTransformTheDataObjectWithoutMapping(): void + { + $data = new class('Freek') extends Data { + /** + * Create the data object from its name. + */ + public function __construct( + #[MapOutputName('snake_name')] + public string $camelName + ) { + } + }; + + $this->assertSame( + ['camelName' => 'Freek'], + $data->transform(TransformationContextFactory::create()->withPropertyNameMapping(false)), + ); + } + + public function testCanMapAnInputPropertyUsingStringWhenCreating(): void + { + $dataClass = new class extends Data { + #[MapInputName('something')] + public string $mapped; + }; + + $data = $dataClass::from([ + 'something' => 'We are the knights who say, ni!', + ]); + + $this->assertSame('We are the knights who say, ni!', $data->mapped); + } + + public function testCanMapAnInputPropertyInNestedObjectsUsingStringsWhenCreating(): void + { + $dataClass = new class extends Data { + #[MapInputName('nested.something')] + public string $mapped; + }; + + $data = $dataClass::from([ + 'nested' => ['something' => 'We are the knights who say, ni!'], + ]); + + $this->assertSame('We are the knights who say, ni!', $data->mapped); + } + + public function testReplacesPropertiesWhenAMappedAlternativeExistsWhenCreating(): void + { + $dataClass = new class extends Data { + #[MapInputName('something')] + public string $mapped; + }; + + $data = $dataClass::from([ + 'mapped' => 'We are the knights who say, ni!', + 'something' => 'Bring us a, shrubbery!', + ]); + + $this->assertSame('Bring us a, shrubbery!', $data->mapped); + } + + public function testSkipsPropertiesItCannotFindWhenCreating(): void + { + $dataClass = new class extends Data { + #[MapInputName('something')] + public string $mapped; + }; + + $data = $dataClass::from([ + 'mapped' => 'We are the knights who say, ni!', + ]); + + $this->assertSame('We are the knights who say, ni!', $data->mapped); + } + + public function testCanUseIntegersToMapPropertiesWhenCreating(): void + { + $dataClass = new class extends Data { + #[MapInputName(1)] + public string $mapped; + }; + + $data = $dataClass::from([ + 'We are the knights who say, ni!', + 'Bring us a, shrubbery!', + ]); + + $this->assertSame('Bring us a, shrubbery!', $data->mapped); + } + + public function testCanUseIntegersToMapPropertiesInNestedDataWhenCreating(): void + { + $dataClass = new class extends Data { + #[MapInputName('1.0')] + public string $mapped; + }; + + $data = $dataClass::from([ + ['We are the knights who say, ni!'], + ['Bring us a, shrubbery!'], + ]); + + $this->assertSame('Bring us a, shrubbery!', $data->mapped); + } + + public function testCanCombineIntegersAndStringsToMapPropertiesWhenCreating(): void + { + $dataClass = new class extends Data { + #[MapInputName('lines.1')] + public string $mapped; + }; + + $data = $dataClass::from([ + 'lines' => [ + 'We are the knights who say, ni!', + 'Bring us a, shrubbery!', + ], + ]); + + $this->assertSame('Bring us a, shrubbery!', $data->mapped); + } + + public function testCanUseASpecialMappingClassWhichConvertsPropertyNamesBetweenStandards(): void + { + $dataClass = new class extends Data { + #[MapInputName(SnakeCaseMapper::class)] + public string $mappedLine; + }; + + $data = $dataClass::from([ + 'mapped_line' => 'We are the knights who say, ni!', + ]); + + $this->assertSame('We are the knights who say, ni!', $data->mappedLine); + } + + public function testCanUseMappedPropertiesToMagicallyCreateData(): void + { + $dataClass = new class extends Data { + #[MapInputName('something')] + public SimpleData $mapped; + }; + + $data = $dataClass::from(new Collection([ + 'something' => 'We are the knights who say, ni!', + ])); + + $this->assertEquals(SimpleData::from('We are the knights who say, ni!'), $data->mapped); + } + + public function testCanUseMappedPropertiesNestedToMagicallyCreateData(): void + { + $dataClass = new class extends Data { + #[MapInputName('something')] + public SimpleDataWithMappedProperty $mapped; + }; + + $data = $dataClass::from(new Collection([ + 'something' => [ + 'description' => 'We are the knights who say, ni!', + ], + ])); + + $this->assertEquals(new SimpleDataWithMappedProperty('We are the knights who say, ni!'), $data->mapped); + } + + public function testCanMapPropertiesWhenCreatingACollectionOfDataObjects(): void + { + $dataClass = new class extends Data { + #[MapInputName('something'), DataCollectionOf(SimpleData::class)] + public array $mapped; + }; + + $data = $dataClass::from(new Collection([ + 'something' => [ + 'We are the knights who say, ni!', + 'Bring us a, shrubbery!', + ], + ])); + + $this->assertEquals( + SimpleData::collect([ + 'We are the knights who say, ni!', + 'Bring us a, shrubbery!', + ]), + $data->mapped, + ); + } + + public function testCanMapPropertiesWhenCreatingANestedCollectionOfDataObjects(): void + { + $dataClass = new class extends Data { + #[MapInputName('something'), DataCollectionOf(SimpleDataWithMappedProperty::class)] + public array $mapped; + }; + + $data = $dataClass::from(new Collection([ + 'something' => [ + ['description' => 'We are the knights who say, ni!'], + ['description' => 'Bring us a, shrubbery!'], + ], + ])); + + $this->assertEquals( + SimpleDataWithMappedProperty::collect([ + ['description' => 'We are the knights who say, ni!'], + ['description' => 'Bring us a, shrubbery!'], + ]), + $data->mapped, + ); + } + + public function testCanUseOneAttributeOnTheClassToMapPropertiesWhenCreating(): void + { + $data = DataWithMapper::from([ + 'cased_property' => 'We are the knights who say, ni!', + 'data_cased_property' => ['string' => 'Bring us a, shrubbery!'], + 'data_collection_cased_property' => [ + ['string' => 'One that looks nice!'], + ['string' => 'But not too expensive!'], + ], + ]); + + $this->assertSame('We are the knights who say, ni!', $data->casedProperty); + $this->assertEquals(SimpleData::from('Bring us a, shrubbery!'), $data->dataCasedProperty); + $this->assertEquals( + SimpleData::collect([ + 'One that looks nice!', + 'But not too expensive!', + ]), + $data->dataCollectionCasedProperty, + ); + } + + public function testHasAMappersBuiltIn(): void + { + $data = new class extends Data { + #[MapName(CamelCaseMapper::class)] + public string $camel_case = 'camelCase'; + + #[MapName(SnakeCaseMapper::class)] + public string $snakeCase = 'snake_case'; + + #[MapName(StudlyCaseMapper::class)] + public string $studly_case = 'StudlyCase'; + + #[MapName(LowerCaseMapper::class)] + public string $lowercase = 'lowercase'; + + #[MapName(UpperCaseMapper::class)] + public string $uppercase = 'UPPERCASE'; + + #[MapName(new ProvidedNameMapper('i_provided'))] + public string $provided = 'provided'; + }; + + $mapped = [ + 'camelCase' => 'camelCase', + 'snake_case' => 'snake_case', + 'StudlyCase' => 'StudlyCase', + 'lowercase' => 'lowercase', + 'UPPERCASE' => 'UPPERCASE', + 'i_provided' => 'provided', + ]; + + $this->assertSame($mapped, $data->toArray()); + + // Upstream supplies the defaults as input; distinct values show each mapped name is read. + $created = $data::from(array_map(static fn (string $value): string => "{$value} input", $mapped)); + + $this->assertSame('camelCase input', $created->camel_case); + $this->assertSame('snake_case input', $created->snakeCase); + $this->assertSame('StudlyCase input', $created->studly_case); + $this->assertSame('lowercase input', $created->lowercase); + $this->assertSame('UPPERCASE input', $created->uppercase); + $this->assertSame('provided input', $created->provided); + } +} diff --git a/tests/Data/Normalizers/FormRequestNormalizerTest.php b/tests/Data/Normalizers/FormRequestNormalizerTest.php new file mode 100644 index 0000000000..169cd36493 --- /dev/null +++ b/tests/Data/Normalizers/FormRequestNormalizerTest.php @@ -0,0 +1,131 @@ + 'Hello', + 'nullableString' => 'World', + ]); + + $this->assertSame(['string' => 'Hello', 'nullableString' => 'World'], $data->toArray()); + } + + public function testCanCreateADataObjectFromFormRequest(): void + { + $request = new class extends FormRequest { + /** + * Get the validation rules. + */ + public function rules(): array + { + return [ + 'string' => 'required|string', + 'nullableString' => 'nullable|string', + ]; + } + }; + $request + ->replace([ + 'string' => 'Hello', + 'nullableString' => 'World', + ]) + ->setContainer($this->app) + ->validateResolved(); + + $this->assertEquals(new DataWithNullable('Hello', 'World'), DataWithNullable::from($request)); + } + + public function testExcludesUnsafeData(): void + { + $request = new class extends FormRequest { + /** + * Get the validation rules. + */ + public function rules(): array + { + return [ + 'string' => 'required|string', + ]; + } + }; + $request + ->replace([ + 'string' => 'Hello', + 'nullableString' => 'World', + ]) + ->setContainer($this->app) + ->validateResolved(); + + $this->assertEquals(new DataWithNullable('Hello', null), DataWithNullable::from($request)); + } + + public function testUnknownFieldsAreCheckedAgainstTheValidatedInput(): void + { + $request = (new UnknownFieldsFormRequest)->replace(['string' => 'Hello', 'extra' => 'excluded'])->setContainer($this->app); + $request->validateResolved(); + + $this->assertSame('Hello', StrictFormRequestData::from($request)->string); + + $request = (new UnknownFieldsFormRequest)->replace(['string' => 'Hello', 'role' => 'admin'])->setContainer($this->app); + $request->validateResolved(); + + try { + StrictFormRequestData::from($request); + $this->fail('Expected validated input the data class does not declare to fail unknown-field validation.'); + } catch (ValidationException $exception) { + $this->assertSame(['role'], array_keys($exception->errors())); + } + } +} + +class UnknownFieldsFormRequest extends FormRequest +{ + /** + * Get the validation rules. + */ + public function rules(): array + { + return [ + 'string' => 'required|string', + 'role' => 'sometimes|string', + ]; + } +} + +#[FailOnUnknownFields] +class StrictFormRequestData extends Data +{ + /** + * Create the strict data object. + */ + public function __construct( + public string $string, + ) { + } +} diff --git a/tests/Data/Normalizers/JsonNormalizerTest.php b/tests/Data/Normalizers/JsonNormalizerTest.php new file mode 100644 index 0000000000..7e4962ff07 --- /dev/null +++ b/tests/Data/Normalizers/JsonNormalizerTest.php @@ -0,0 +1,52 @@ +toJson()); + + $this->assertEquals($originalData->all(), $createdData->all()); + } + + #[DataProvider('unreadableValues')] + public function testWontCreateADataObjectFromAValueThatIsNotAJsonObject(mixed $value): void + { + $this->expectException(CannotCreateData::class); + + MultiData::from($value); + } + + /** + * Get the values upstream's three rejection cases use. + */ + public static function unreadableValues(): iterable + { + yield "won't create a data object from a regular string" => ['Hello World']; + yield "won't create a data object from an integer" => [1234]; + yield "won't create a data object from a string containing only an integer" => ['1234']; + } +} diff --git a/tests/Data/Normalizers/ModelNormalizerTest.php b/tests/Data/Normalizers/ModelNormalizerTest.php new file mode 100644 index 0000000000..9a57183e75 --- /dev/null +++ b/tests/Data/Normalizers/ModelNormalizerTest.php @@ -0,0 +1,264 @@ + $this->getRefreshConnection(), + '--realpath' => true, + '--path' => __DIR__ . '/../Fixtures/Migrations', + ]; + } + + public function testCanGetADataObjectFromModel(): void + { + $model = FakeModel::factory()->create(); + $data = FakeModelData::from($model); + + $this->assertSame($model->string, $data->string); + $this->assertSame($model->nullable, $data->nullable); + $this->assertEquals($model->date, $data->date); + $this->assertSame('translated_string', $data->translated); + } + + public function testDoesNotLoopInfinitelyOnRelations(): void + { + $parentModel = FakeModel::factory()->makeOne(); + $childModel = FakeNestedModel::factory()->makeOne(); + + $childModel->setRelation('parent', $parentModel); + $parentModel->setRelation('pivot', $childModel); + + $data = FakeModelData::from($parentModel); + + $this->assertSame($parentModel->string, $data->string); + $this->assertSame($parentModel->nullable, $data->nullable); + $this->assertEquals($parentModel->date, $data->date); + } + + public function testCanGetADataObjectWithNestingFromModelAndRelationsWhenLoaded(): void + { + $model = FakeModel::factory()->create(); + + $nestedModelA = FakeNestedModel::factory()->for($model)->create(); + $nestedModelB = FakeNestedModel::factory()->for($model)->create(); + + $data = FakeModelData::from($model->load('fakeNestedModels')); + + $this->assertSame($model->string, $data->string); + $this->assertSame($model->nullable, $data->nullable); + $this->assertEquals($model->date, $data->date); + $this->assertCount(2, $data->fake_nested_models); + + foreach ([$nestedModelA, $nestedModelB] as $index => $nestedModel) { + $this->assertSame($nestedModel->string, $data->fake_nested_models[$index]->string); + $this->assertSame($nestedModel->nullable, $data->fake_nested_models[$index]->nullable); + $this->assertEquals($nestedModel->date, $data->fake_nested_models[$index]->date); + } + } + + public function testCanGetADataObjectFromModelWithAccessors(): void + { + $model = FakeModel::factory()->create(); + $data = FakeModelData::from($model); + + $this->assertSame($model->accessor, $data->accessor); + $this->assertSame($model->old_accessor, $data->old_accessor); + } + + public function testWillOnlyCallModelAccessorsWhenRequired(): void + { + $dataClass = new class extends Data { + public string $accessor; + + public string $old_accessor; + }; + + $data = $dataClass::from(FakeModel::factory()->create()); + + $this->assertStringStartsWith('accessor_', $data->accessor); + + foreach ([ + 'This attribute should not be called' => new class extends Data { + public string $performance_heavy; + }, + 'This accessor should not be called' => new class extends Data { + public string $performance_heavy_accessor; + }, + ] as $message => $dataClass) { + try { + $dataClass::from(FakeModel::factory()->create()); + $this->fail("Expected [{$message}]."); + } catch (Exception $exception) { + $this->assertSame($message, $exception->getMessage()); + } + } + } + + public function testWillReturnNullForNonExistingProperties(): void + { + $dataClass = new class extends Data { + public ?string $non_existing_property; + }; + + $data = $dataClass::from(FakeModel::factory()->create()); + + $this->assertNull($data->non_existing_property); + } + + public function testCanLoadRelationsOnAModelWhenRequiredAndTheLoadRelationAttributeIsSet(): void + { + $model = FakeModel::factory()->create(); + + FakeNestedModel::factory()->for($model)->create(); + FakeNestedModel::factory()->for($model)->create(); + + $dataClass = new class extends Data { + #[LoadRelation, DataCollectionOf(FakeNestedModelData::class)] + public array $fake_nested_models; + + #[LoadRelation, DataCollectionOf(FakeNestedModelData::class)] + public array $fake_nested_models_snake_cased; + }; + + $model->load('fake_nested_models_snake_cased'); + DB::enableQueryLog(); + + $data = $dataClass::from($model); + + $queryLog = DB::getQueryLog(); + + $this->assertCount(2, $data->fake_nested_models); + $this->assertContainsOnlyInstancesOf(FakeNestedModelData::class, $data->fake_nested_models); + $this->assertCount(2, $data->fake_nested_models_snake_cased); + $this->assertContainsOnlyInstancesOf(FakeNestedModelData::class, $data->fake_nested_models_snake_cased); + $this->assertCount(1, $queryLog); + } + + public function testWillNotAutomaticallyLoadRelationWhenTheLoadRelationAttributeIsNotSet(): void + { + $model = FakeModel::factory()->create(); + + FakeNestedModel::factory()->for($model)->create(); + FakeNestedModel::factory()->for($model)->create(); + + $optionalClass = new class extends Data { + #[DataCollectionOf(FakeNestedModelData::class)] + public array|Optional $fake_nested_models; + }; + + DB::enableQueryLog(); + + $data = $optionalClass::from($model); + + $this->assertInstanceOf(Optional::class, $data->fake_nested_models); + $this->assertCount(0, DB::getQueryLog()); + + $nullableClass = new class extends Data { + public ?array $fake_nested_models = null; + }; + + DB::flushQueryLog(); + + $data = $nullableClass::from($model); + + $this->assertNull($data->fake_nested_models); + $this->assertCount(0, DB::getQueryLog()); + } + + public function testCanUseMappersToMapTheNames(): void + { + $model = FakeModel::factory()->create(); + + FakeNestedModel::factory()->for($model)->create(); + FakeNestedModel::factory()->for($model)->create(); + + $dataClass = new class extends Data { + #[DataCollectionOf(FakeNestedModelData::class), MapInputName(SnakeCaseMapper::class)] + public array|Optional $fakeNestedModels; + + #[MapInputName(SnakeCaseMapper::class)] + public string $oldAccessor; + }; + + $data = $dataClass::from($model->load('fakeNestedModels')); + + $this->assertCount(2, $data->fakeNestedModels); + $this->assertContainsOnlyInstancesOf(FakeNestedModelData::class, $data->fakeNestedModels); + $this->assertSame($model->old_accessor, $data->oldAccessor); + } + + public function testCanCreateADataPropertyForAModelAttributeWhichFetchesARelationThatIsLoadedAndItWillNotTriggerALazyLoadingException(): void + { + $dataClass = new class('') extends Data { + /** + * Create the data object from an attribute that reads a relation. + */ + public function __construct(public string $accessor_using_relation) + { + } + }; + + $model = FakeModel::factory()->create(); + FakeNestedModel::factory()->for($model)->create(); + + $freshModel = FakeModel::query()->first(); + + $freshModel->preventsLazyLoading = true; + + try { + $freshModel->append('accessorUsingRelation'); + $dataClass::from($freshModel); + $this->fail('Expected reading the unloaded relation to be prevented.'); + } catch (LazyLoadingViolationException) { + } + + $freshModel = $freshModel + ->load('fakeNestedModels') + ->append('accessorUsingRelation'); + + $data = $dataClass::from($freshModel); + + $this->assertSame($freshModel->accessor_using_relation, $data->accessor_using_relation); + } +} diff --git a/tests/Data/Normalizers/Normalized/NormalizedModelTest.php b/tests/Data/Normalizers/Normalized/NormalizedModelTest.php index cb6b3cab97..cba8404131 100644 --- a/tests/Data/Normalizers/Normalized/NormalizedModelTest.php +++ b/tests/Data/Normalizers/Normalized/NormalizedModelTest.php @@ -9,12 +9,14 @@ use Hypervel\Data\Attributes\LoadRelation; use Hypervel\Data\Normalizers\Normalized\NormalizedModel; use Hypervel\Data\Normalizers\Normalized\UnknownProperty; +use Hypervel\Data\Support\Annotations\DataIterableAnnotationReader; use Hypervel\Data\Support\DataConfig; use Hypervel\Data\Support\DataProperty; use Hypervel\Data\Support\Factories\DataPropertyFactory; use Hypervel\Data\Support\Factories\DataTypeFactory; use Hypervel\Data\Support\NameMapperResolver; use Hypervel\Data\Support\Types\PhpDocTypeNameResolver; +use Hypervel\Database\Eloquent\Casts\Attribute; use Hypervel\Database\Eloquent\Model; use Hypervel\Tests\TestCase; use ReflectionClass; @@ -23,7 +25,7 @@ class NormalizedModelTest extends TestCase { /** - * Test only declared model attributes are read and null remains present. + * Test only declared model attributes are read, and null is present only when loaded or returned by a getter. */ public function testReadsDeclaredSnakeCaseAttributesWithoutSerializingTheModel(): void { @@ -37,6 +39,15 @@ public function testReadsDeclaredSnakeCaseAttributesWithoutSerializingTheModel() $this->assertSame('Taylor', $source->getProperty('firstName', $this->property('firstName'))); $this->assertNull($source->getProperty('nullableName', $this->property('nullableName'))); + $this->assertNull($source->getProperty('nullGetter', $this->property('nullGetter'))); + $this->assertSame( + UnknownProperty::create(), + $source->getProperty('castColumn', $this->property('castColumn')), + ); + $this->assertSame( + UnknownProperty::create(), + $source->getProperty('setterOnly', $this->property('setterOnly')), + ); $this->assertSame( UnknownProperty::create(), $source->getProperty('missing', $this->property('missing')), @@ -79,6 +90,25 @@ public function testLoadsOnlyRelationsMarkedForLoading(): void $this->assertSame(1, $model->loadMissingCount); } + public function testReadsNamesSuppliedByAnOverriddenGetAttribute(): void + { + $model = new NormalizedModelFixture; + $model->exists = true; + NormalizedModelFixture::preventAccessingMissingAttributes(); + $source = new NormalizedModel($model); + + $this->assertSame('translated_string', $source->getProperty('translated', $this->property('translated'))); + $this->assertSame( + UnknownProperty::create(), + $source->getProperty('missing', $this->property('missing')), + ); + $this->assertSame( + UnknownProperty::create(), + $source->getProperty('profile', $this->property('profile')), + ); + $this->assertSame(0, $model->loadMissingCount); + } + /** * Build property metadata for the model projection fixture. */ @@ -86,7 +116,7 @@ protected function property(string $name): DataProperty { $defaults = require __DIR__ . '/../../../../src/data/config/data.php'; $config = new DataConfig(new Repository(['data' => $defaults])); - $typeFactory = new DataTypeFactory(new PhpDocTypeNameResolver); + $typeFactory = new DataTypeFactory(new PhpDocTypeNameResolver, new DataIterableAnnotationReader); $reflectionClass = new ReflectionClass(NormalizedModelDataFixture::class); return (new DataPropertyFactory( @@ -106,8 +136,16 @@ class NormalizedModelDataFixture public ?string $nullableName; + public ?string $castColumn; + + public ?string $nullGetter; + + public ?string $setterOnly; + public string $missing; + public string $translated; + public ?object $profile; #[LoadRelation] @@ -116,6 +154,10 @@ class NormalizedModelDataFixture class NormalizedModelFixture extends Model { + protected array $casts = [ + 'cast_column' => 'string', + ]; + public int $serializationCount = 0; public int $loadMissingCount = 0; @@ -128,6 +170,30 @@ public function isRelation(string $key): bool return in_array($key, ['profile', 'loadedProfile'], true); } + /** + * Supply a translated value that is not a model attribute. + */ + public function getAttribute(string $key): mixed + { + return $key === 'translated' ? 'translated_string' : parent::getAttribute($key); + } + + /** + * Get an accessor that returns null. + */ + protected function nullGetter(): Attribute + { + return Attribute::get(static fn (): ?string => null); + } + + /** + * Get an attribute that only has a setter. + */ + protected function setterOnly(): Attribute + { + return Attribute::set(static fn (string $value): string => $value); + } + /** * Mark a requested fixture relation as loaded. */ diff --git a/tests/Data/PartialsTest.php b/tests/Data/PartialsTest.php new file mode 100644 index 0000000000..8517584669 --- /dev/null +++ b/tests/Data/PartialsTest.php @@ -0,0 +1,1678 @@ +toResponse(Request::create('/')); + + $this->assertSame([], $response->getData(true)); + + LazyData::setAllowedIncludes(['name']); + + $includedResponse = LazyData::from('Ruben')->toResponse(Request::create('/', 'GET', [ + 'include' => 'name', + ])); + + $this->assertSame(['name' => 'Ruben'], $includedResponse->getData(true)); + } + + public function testCanDisabledIncludingDataDynamicallyFromTheRequest(): void + { + LazyData::setAllowedIncludes([]); + + $response = LazyData::from('Ruben')->toResponse(Request::create('/', 'GET', [ + 'include' => 'name', + ])); + + $this->assertSame([], $response->getData(true)); + + LazyData::setAllowedIncludes(['name']); + + $response = LazyData::from('Ruben')->toResponse(Request::create('/', 'GET', [ + 'include' => 'name', + ])); + + $this->assertSame(['name' => 'Ruben'], $response->getData(true)); + + LazyData::setAllowedIncludes(null); + + $response = LazyData::from('Ruben')->toResponse(Request::create('/', 'GET', [ + 'include' => 'name', + ])); + + $this->assertSame(['name' => 'Ruben'], $response->getData(true)); + } + + public function testCanDynamicallyExcludeDataBasedUponTheRequest(): void + { + DefaultLazyData::setAllowedExcludes([]); + + $response = DefaultLazyData::from('Ruben')->toResponse(Request::create('/')); + + $this->assertSame(['name' => 'Ruben'], $response->getData(true)); + + DefaultLazyData::setAllowedExcludes(['name']); + + $excludedResponse = DefaultLazyData::from('Ruben')->toResponse(Request::create('/', 'GET', [ + 'exclude' => 'name', + ])); + + $this->assertSame([], $excludedResponse->getData(true)); + } + + public function testCanDisableExcludingDataDynamicallyFromTheRequest(): void + { + DefaultLazyData::setAllowedExcludes([]); + + $response = DefaultLazyData::from('Ruben')->toResponse(Request::create('/', 'GET', [ + 'exclude' => 'name', + ])); + + $this->assertSame(['name' => 'Ruben'], $response->getData(true)); + + DefaultLazyData::setAllowedExcludes(['name']); + + $response = DefaultLazyData::from('Ruben')->toResponse(Request::create('/', 'GET', [ + 'exclude' => 'name', + ])); + + $this->assertSame([], $response->getData(true)); + + DefaultLazyData::setAllowedExcludes(null); + + $response = DefaultLazyData::from('Ruben')->toResponse(Request::create('/', 'GET', [ + 'exclude' => 'name', + ])); + + $this->assertSame([], $response->getData(true)); + } + + public function testCanDisableOnlyDataDynamicallyFromTheRequest(): void + { + OnlyData::setAllowedOnly([]); + + $response = OnlyData::from([ + 'first_name' => 'Ruben', + 'last_name' => 'Van Assche', + ])->toResponse(Request::create('/', 'GET', [ + 'only' => 'first_name', + ])); + + $this->assertSame([ + 'first_name' => 'Ruben', + 'last_name' => 'Van Assche', + ], $response->getData(true)); + + OnlyData::setAllowedOnly(['first_name']); + + $response = OnlyData::from(['first_name' => 'Ruben', 'last_name' => 'Van Assche'])->toResponse(Request::create('/', 'GET', [ + 'only' => 'first_name', + ])); + + $this->assertSame([ + 'first_name' => 'Ruben', + ], $response->getData(true)); + + OnlyData::setAllowedOnly(null); + + $response = OnlyData::from(['first_name' => 'Ruben', 'last_name' => 'Van Assche'])->toResponse(Request::create('/', 'GET', [ + 'only' => 'first_name', + ])); + + $this->assertSame([ + 'first_name' => 'Ruben', + ], $response->getData(true)); + } + + public function testCanDisableExceptDataDynamicallyFromTheRequest(): void + { + ExceptData::setAllowedExcept([]); + + $response = ExceptData::from(['first_name' => 'Ruben', 'last_name' => 'Van Assche'])->toResponse(Request::create('/', 'GET', [ + 'except' => 'first_name', + ])); + + $this->assertSame([ + 'first_name' => 'Ruben', + 'last_name' => 'Van Assche', + ], $response->getData(true)); + + ExceptData::setAllowedExcept(['first_name']); + + $response = ExceptData::from(['first_name' => 'Ruben', 'last_name' => 'Van Assche'])->toResponse(Request::create('/', 'GET', [ + 'except' => 'first_name', + ])); + + $this->assertSame([ + 'last_name' => 'Van Assche', + ], $response->getData(true)); + + ExceptData::setAllowedExcept(null); + + $response = ExceptData::from(['first_name' => 'Ruben', 'last_name' => 'Van Assche'])->toResponse(Request::create('/', 'GET', [ + 'except' => 'first_name', + ])); + + $this->assertSame([ + 'last_name' => 'Van Assche', + ], $response->getData(true)); + } + + public function testCanConditionallyInclude(): void + { + $this->assertSame( + [], + MultiLazyData::from(DummyDto::rick())->includeWhen('artist', false)->toArray(), + ); + + $this->assertSame([ + 'artist' => 'Rick Astley', + ], MultiLazyData::from(DummyDto::rick()) + ->includeWhen('artist', true) + ->toArray()); + + $this->assertSame([ + 'name' => 'Never gonna give you up', + ], MultiLazyData::from(DummyDto::rick()) + ->includeWhen('name', fn (MultiLazyData $data): bool => $data->artist->resolve() === 'Rick Astley') + ->toArray()); + } + + public function testCanConditionallyIncludeNested(): void + { + $data = new class extends Data { + public NestedLazyData $nested; + }; + + $data->nested = NestedLazyData::from('Hello World'); + + $this->assertSame(['nested' => []], $data->toArray()); + + $this->assertSame([ + 'nested' => ['simple' => ['string' => 'Hello World']], + ], $data->includeWhen('nested.simple', true)->toArray()); + } + + public function testCanConditionallyIncludeNestedCollection(): void + { + $dataClass = new class extends Data { + #[DataCollectionOf(MultiLazyData::class)] + public Collection $nested; + }; + + $data = $dataClass::collect([ + [ + 'nested' => [DummyDto::rick()], + ], [ + 'nested' => [DummyDto::bon()], + ], + ], DataCollection::class); + + $this->assertSame([ + ['nested' => [[]]], + ['nested' => [[]]], + ], $data->toArray()); + + $this->assertSame([ + ['nested' => [['artist' => DummyDto::rick()->artist, 'year' => DummyDto::rick()->year]]], + ['nested' => [['artist' => DummyDto::bon()->artist, 'year' => DummyDto::bon()->year]]], + ], $data->include('nested.{artist,year}')->toArray()); + } + + public function testCanConditionallyIncludeUsingClassDefaults(): void + { + PartialClassConditionalData::setDefinitions(includeDefinitions: [ + 'string' => fn (PartialClassConditionalData $data): bool => $data->enabled, + ]); + + $this->assertSame(['enabled' => false], PartialClassConditionalData::createLazy(enabled: false)->toArray()); + + $this->assertSame( + ['enabled' => true, 'string' => 'Hello World'], + PartialClassConditionalData::createLazy(enabled: true)->toArray(), + ); + } + + public function testCanConditionallyIncludeUsingClassDefaultsNested(): void + { + PartialClassConditionalData::setDefinitions(includeDefinitions: [ + 'nested.string' => fn (PartialClassConditionalData $data): bool => $data->enabled, + ]); + + $this->assertSame( + ['enabled' => true, 'nested' => ['string' => 'Hello World']], + PartialClassConditionalData::createLazy(enabled: true)->toArray(), + ); + } + + public function testCanConditionallyIncludeUsingClassDefaultsMultiple(): void + { + PartialClassConditionalData::setDefinitions(includeDefinitions: [ + 'nested.string' => fn (PartialClassConditionalData $data): bool => $data->enabled, + 'string' => fn (PartialClassConditionalData $data): bool => $data->enabled, + ]); + + $this->assertSame(['enabled' => false], PartialClassConditionalData::createLazy(enabled: false)->toArray()); + + $this->assertSame([ + 'enabled' => true, + 'string' => 'Hello World', + 'nested' => ['string' => 'Hello World'], + ], PartialClassConditionalData::createLazy(enabled: true)->toArray()); + } + + public function testCanConditionallyExclude(): void + { + $data = new MultiLazyData( + Lazy::create(fn (): string => 'Rick Astley')->defaultIncluded(), + Lazy::create(fn (): string => 'Never gonna give you up')->defaultIncluded(), + 1989 + ); + + $this->assertSame([ + 'artist' => 'Rick Astley', + 'name' => 'Never gonna give you up', + 'year' => 1989, + ], (clone $data)->exceptWhen('artist', false)->toArray()); + + $this->assertSame([ + 'name' => 'Never gonna give you up', + 'year' => 1989, + ], (clone $data)->exceptWhen('artist', true)->toArray()); + + $this->assertSame([ + 'artist' => 'Rick Astley', + 'year' => 1989, + ], (clone $data) + ->exceptWhen('name', fn (MultiLazyData $data): bool => $data->artist->resolve() === 'Rick Astley') + ->toArray()); + } + + public function testCanConditionallyExcludeNested(): void + { + $data = new class extends Data { + public NestedLazyData $nested; + }; + + $data->nested = new NestedLazyData(Lazy::create(fn (): SimpleData => SimpleData::from('Hello World'))->defaultIncluded()); + + $this->assertSame([ + 'nested' => ['simple' => ['string' => 'Hello World']], + ], $data->toArray()); + + $this->assertSame(['nested' => []], $data->exceptWhen('nested.simple', true)->toArray()); + } + + public function testCanConditionallyExcludeUsingClassDefaults(): void + { + PartialClassConditionalData::setDefinitions(excludeDefinitions: [ + 'string' => fn (PartialClassConditionalData $data): bool => $data->enabled, + ]); + + $this->assertSame([ + 'enabled' => false, + 'string' => 'Hello World', + 'nested' => ['string' => 'Hello World'], + ], PartialClassConditionalData::createDefaultIncluded(enabled: false)->toArray()); + + $this->assertSame([ + 'enabled' => true, + 'nested' => ['string' => 'Hello World'], + ], PartialClassConditionalData::createDefaultIncluded(enabled: true)->toArray()); + } + + public function testCanConditionallyExcludeUsingClassDefaultsNested(): void + { + PartialClassConditionalData::setDefinitions(excludeDefinitions: [ + 'nested.string' => fn (PartialClassConditionalData $data): bool => $data->enabled, + ]); + + $this->assertSame([ + 'enabled' => false, + 'string' => 'Hello World', + 'nested' => ['string' => 'Hello World'], + ], PartialClassConditionalData::createDefaultIncluded(enabled: false)->toArray()); + + // Exclusions only hide lazy properties, and the nested string isn't lazy. + $this->assertSame([ + 'enabled' => true, + 'string' => 'Hello World', + 'nested' => ['string' => 'Hello World'], + ], PartialClassConditionalData::createDefaultIncluded(enabled: true)->toArray()); + } + + public function testCanConditionallyExcludeUsingMultipleClassDefaults(): void + { + PartialClassConditionalData::setDefinitions(excludeDefinitions: [ + 'string' => fn (PartialClassConditionalData $data): bool => $data->enabled, + 'nested.string' => fn (PartialClassConditionalData $data): bool => $data->enabled, + ]); + + $this->assertSame([ + 'enabled' => false, + 'string' => 'Hello World', + 'nested' => ['string' => 'Hello World'], + ], PartialClassConditionalData::createDefaultIncluded(enabled: false)->toArray()); + + // Exclusions only hide lazy properties, and the nested string isn't lazy. + $this->assertSame([ + 'enabled' => true, + 'nested' => ['string' => 'Hello World'], + ], PartialClassConditionalData::createDefaultIncluded(enabled: true)->toArray()); + } + + public function testCanConditionallyDefineOnly(): void + { + $data = new MultiData('Hello', 'World'); + + $this->assertSame([ + 'first' => 'Hello', + ], (clone $data)->onlyWhen('first', true)->toArray()); + + $this->assertSame([ + 'first' => 'Hello', + 'second' => 'World', + ], (clone $data)->onlyWhen('first', false)->toArray()); + + $this->assertSame(['second' => 'World'], (clone $data) + ->onlyWhen('second', fn (MultiData $data): bool => $data->second === 'World') + ->toArray()); + + $this->assertSame([ + 'first' => 'Hello', + 'second' => 'World', + ], (clone $data) + ->onlyWhen('first', fn (MultiData $data): bool => $data->first === 'Hello') + ->onlyWhen('second', fn (MultiData $data): bool => $data->second === 'World') + ->toArray()); + } + + public function testCanConditionallyDefineOnlyNested(): void + { + $data = new class extends Data { + public MultiData $nested; + }; + + $data->nested = new MultiData('Hello', 'World'); + + $this->assertSame([ + 'nested' => ['first' => 'Hello'], + ], (clone $data)->onlyWhen('nested.first', true)->toArray()); + + $this->assertSame([ + 'nested' => [ + 'first' => 'Hello', + 'second' => 'World', + ], + ], (clone $data)->onlyWhen('nested.{first, second}', true)->toArray()); + } + + public function testCanConditionallyDefineOnlyUsingClassDefaults(): void + { + PartialClassConditionalData::setDefinitions(onlyDefinitions: [ + 'string' => fn (PartialClassConditionalData $data): bool => $data->enabled, + ]); + + $this->assertSame([ + 'enabled' => false, + 'string' => 'Hello World', + 'nested' => ['string' => 'Hello World'], + ], PartialClassConditionalData::create(enabled: false)->toArray()); + + $this->assertSame(['string' => 'Hello World'], PartialClassConditionalData::create(enabled: true)->toArray()); + } + + public function testCanConditionallyDefineOnlyUsingClassDefaultsNested(): void + { + PartialClassConditionalData::setDefinitions(onlyDefinitions: [ + 'nested.string' => fn (PartialClassConditionalData $data): bool => $data->enabled, + ]); + + $this->assertSame([ + 'enabled' => false, + 'string' => 'Hello World', + 'nested' => ['string' => 'Hello World'], + ], PartialClassConditionalData::create(enabled: false)->toArray()); + + $this->assertSame([ + 'nested' => ['string' => 'Hello World'], + ], PartialClassConditionalData::create(enabled: true)->toArray()); + } + + public function testCanConditionallyDefineOnlyUsingMultipleClassDefaults(): void + { + PartialClassConditionalData::setDefinitions(onlyDefinitions: [ + 'string' => fn (PartialClassConditionalData $data): bool => $data->enabled, + 'nested.string' => fn (PartialClassConditionalData $data): bool => $data->enabled, + ]); + + $this->assertSame([ + 'enabled' => false, + 'string' => 'Hello World', + 'nested' => ['string' => 'Hello World'], + ], PartialClassConditionalData::create(enabled: false)->toArray()); + + $this->assertSame([ + 'string' => 'Hello World', + 'nested' => ['string' => 'Hello World'], + ], PartialClassConditionalData::create(enabled: true)->toArray()); + } + + public function testCanConditionallyDefineExcept(): void + { + $data = new MultiData('Hello', 'World'); + + $this->assertSame(['second' => 'World'], (clone $data)->exceptWhen('first', true)->toArray()); + + $this->assertSame([ + 'first' => 'Hello', + 'second' => 'World', + ], (clone $data)->exceptWhen('first', false)->toArray()); + + $this->assertSame([ + 'first' => 'Hello', + ], (clone $data) + ->exceptWhen('second', fn (MultiData $data): bool => $data->second === 'World') + ->toArray()); + + $this->assertSame([], (clone $data) + ->exceptWhen('first', fn (MultiData $data): bool => $data->first === 'Hello') + ->exceptWhen('second', fn (MultiData $data): bool => $data->second === 'World') + ->toArray()); + } + + public function testCanConditionallyDefineExceptNested(): void + { + $data = new class extends Data { + public MultiData $nested; + }; + + $data->nested = new MultiData('Hello', 'World'); + + $this->assertSame(['nested' => ['second' => 'World']], (clone $data)->exceptWhen('nested.first', true)->toArray()); + + $this->assertSame(['nested' => []], (clone $data)->exceptWhen('nested.{first, second}', true)->toArray()); + } + + public function testCanConditionallyDefineExceptUsingClassDefaults(): void + { + PartialClassConditionalData::setDefinitions(exceptDefinitions: [ + 'string' => fn (PartialClassConditionalData $data): bool => $data->enabled, + ]); + + $this->assertSame([ + 'enabled' => false, + 'string' => 'Hello World', + 'nested' => ['string' => 'Hello World'], + ], PartialClassConditionalData::create(enabled: false)->toArray()); + + $this->assertSame([ + 'enabled' => true, + 'nested' => ['string' => 'Hello World'], + ], PartialClassConditionalData::create(enabled: true)->toArray()); + } + + public function testCanConditionallyDefineExceptUsingClassDefaultsNested(): void + { + PartialClassConditionalData::setDefinitions(exceptDefinitions: [ + 'nested.string' => fn (PartialClassConditionalData $data): bool => $data->enabled, + ]); + + $this->assertSame([ + 'enabled' => false, + 'string' => 'Hello World', + 'nested' => ['string' => 'Hello World'], + ], PartialClassConditionalData::create(enabled: false)->toArray()); + + $this->assertSame([ + 'enabled' => true, + 'string' => 'Hello World', + 'nested' => [], + ], PartialClassConditionalData::create(enabled: true)->toArray()); + } + + public function testCanConditionallyDefineExceptUsingMultipleClassDefaults(): void + { + PartialClassConditionalData::setDefinitions(exceptDefinitions: [ + 'string' => fn (PartialClassConditionalData $data): bool => $data->enabled, + 'nested.string' => fn (PartialClassConditionalData $data): bool => $data->enabled, + ]); + + $this->assertSame([ + 'enabled' => false, + 'string' => 'Hello World', + 'nested' => ['string' => 'Hello World'], + ], PartialClassConditionalData::create(enabled: false)->toArray()); + + $this->assertSame([ + 'enabled' => true, + 'nested' => [], + ], PartialClassConditionalData::create(enabled: true)->toArray()); + } + + public function testCanPerformOnlyAndExceptOnArrayProperties(): void + { + $data = new class('Hello World', ['string' => 'Hello World', 'int' => 42]) extends Data { + /** + * Create a data object with a string and an array. + */ + public function __construct( + public string $string, + public array $array + ) { + } + }; + + $this->assertSame([ + 'string' => 'Hello World', + 'array' => ['int' => 42], + ], (clone $data)->only('string', 'array.int')->toArray()); + + $this->assertSame([ + 'array' => ['string' => 'Hello World'], + ], (clone $data)->except('string', 'array.int')->toArray()); + } + + public function testCanFetchLazyPropertiesLikeRegularPropertiesWithinPhp(): void + { + $dataClass = new class extends Data { + public int $id; + + public SimpleData|Lazy $simple; + + #[DataCollectionOf(SimpleData::class)] + public DataCollection|Lazy $dataCollection; + + public FakeModel|Lazy $fakeModel; + }; + + $data = $dataClass::from([ + 'id' => 42, + 'simple' => Lazy::create(fn (): SimpleData => SimpleData::from('A')), + 'dataCollection' => Lazy::create(fn (): DataCollection => SimpleData::collect(['B', 'C'], DataCollection::class)), + // Upstream persists the model; reading it through the lazy property needs no database. + 'fakeModel' => Lazy::create(fn (): FakeModel => FakeModel::factory()->make([ + 'string' => 'lazy', + ])), + ]); + + $this->assertSame(42, $data->id); + $this->assertSame('A', $data->simple->string); + $this->assertSame(['B', 'C'], $data->dataCollection->toCollection()->pluck('string')->toArray()); + $this->assertSame('lazy', $data->fakeModel->string); + } + + public function testHasArrayAccessAndWillReplicatePartialsCollection(): void + { + $collection = MultiData::collect([ + new MultiData('first', 'second'), + ], DataCollection::class)->only('second'); + + $this->assertSame(['second' => 'second'], $collection[0]->toArray()); + } + + public function testCanDynamicallyIncludeDataBasedUponTheRequestCollection(): void + { + LazyData::setAllowedIncludes(['']); + + $response = (new DataCollection(LazyData::class, ['Ruben', 'Freek', 'Brent']))->toResponse(Request::create('/')); + + $this->assertSame([ + [], + [], + [], + ], $response->getData(true)); + + LazyData::setAllowedIncludes(['name']); + + $includedResponse = (new DataCollection(LazyData::class, ['Ruben', 'Freek', 'Brent']))->toResponse(Request::create('/', 'GET', [ + 'include' => 'name', + ])); + + $this->assertSame([ + ['name' => 'Ruben'], + ['name' => 'Freek'], + ['name' => 'Brent'], + ], $includedResponse->getData(true)); + } + + public function testCanDisableManuallyIncludingDataInTheRequestCollection(): void + { + LazyData::setAllowedIncludes([]); + + $response = (new DataCollection(LazyData::class, ['Ruben', 'Freek', 'Brent']))->toResponse(Request::create('/', 'GET', [ + 'include' => 'name', + ])); + + $this->assertSame([ + [], + [], + [], + ], $response->getData(true)); + + LazyData::setAllowedIncludes(['name']); + + $response = (new DataCollection(LazyData::class, ['Ruben', 'Freek', 'Brent']))->toResponse(Request::create('/', 'GET', [ + 'include' => 'name', + ])); + + $this->assertSame([ + ['name' => 'Ruben'], + ['name' => 'Freek'], + ['name' => 'Brent'], + ], $response->getData(true)); + + LazyData::setAllowedIncludes(null); + + $response = (new DataCollection(LazyData::class, ['Ruben', 'Freek', 'Brent']))->toResponse(Request::create('/', 'GET', [ + 'include' => 'name', + ])); + + $this->assertSame([ + ['name' => 'Ruben'], + ['name' => 'Freek'], + ['name' => 'Brent'], + ], $response->getData(true)); + } + + public function testCanDynamicallyExcludeDataBasedUponTheRequestCollection(): void + { + DefaultLazyData::setAllowedExcludes([]); + + $response = (new DataCollection(DefaultLazyData::class, ['Ruben', 'Freek', 'Brent']))->toResponse(Request::create('/')); + + $this->assertSame([ + ['name' => 'Ruben'], + ['name' => 'Freek'], + ['name' => 'Brent'], + ], $response->getData(true)); + + DefaultLazyData::setAllowedExcludes(['name']); + + $excludedResponse = (new DataCollection(DefaultLazyData::class, ['Ruben', 'Freek', 'Brent']))->toResponse(Request::create('/', 'GET', [ + 'exclude' => 'name', + ])); + + $this->assertSame([ + [], + [], + [], + ], $excludedResponse->getData(true)); + } + + public function testCanDisableManuallyExcludingDataInTheRequestCollection(): void + { + DefaultLazyData::setAllowedExcludes([]); + + $response = (new DataCollection(DefaultLazyData::class, ['Ruben', 'Freek', 'Brent']))->toResponse(Request::create('/', 'GET', [ + 'exclude' => 'name', + ])); + + $this->assertSame([ + ['name' => 'Ruben'], + ['name' => 'Freek'], + ['name' => 'Brent'], + ], $response->getData(true)); + + DefaultLazyData::setAllowedExcludes(['name']); + + $response = (new DataCollection(DefaultLazyData::class, ['Ruben', 'Freek', 'Brent']))->toResponse(Request::create('/', 'GET', [ + 'exclude' => 'name', + ])); + + $this->assertSame([ + [], + [], + [], + ], $response->getData(true)); + + DefaultLazyData::setAllowedExcludes(null); + + $response = (new DataCollection(DefaultLazyData::class, ['Ruben', 'Freek', 'Brent']))->toResponse(Request::create('/', 'GET', [ + 'exclude' => 'name', + ])); + + $this->assertSame([ + [], + [], + [], + ], $response->getData(true)); + } + + public function testCanWorkWithLazyArrayDataCollections(): void + { + $dataClass = new class extends Data { + #[DataCollectionOf(SimpleData::class)] + public Lazy|array $lazyCollection; + + #[DataCollectionOf(NestedLazyData::class)] + public Lazy|array $nestedLazyCollection; + }; + + $dataClass->lazyCollection = Lazy::create(fn (): array => [ + SimpleData::from('A'), + SimpleData::from('B'), + ]); + + $dataClass->nestedLazyCollection = Lazy::create(fn (): array => [ + NestedLazyData::from('C'), + NestedLazyData::from('D'), + ]); + + $this->assertSame([], $dataClass->toArray()); + + $this->assertSame([ + 'lazyCollection' => [ + ['string' => 'A'], + ['string' => 'B'], + ], + ], $dataClass->include('lazyCollection')->toArray()); + + $this->assertSame([ + 'lazyCollection' => [ + ['string' => 'A'], + ['string' => 'B'], + ], + + 'nestedLazyCollection' => [ + ['simple' => ['string' => 'C']], + ['simple' => ['string' => 'D']], + ], + ], $dataClass->include('lazyCollection', 'nestedLazyCollection.simple')->toArray()); + } + + public function testCanWorkWithLazyHypervelDataCollections(): void + { + $dataClass = new class extends Data { + #[DataCollectionOf(SimpleData::class)] + public Lazy|Collection $lazyCollection; + + #[DataCollectionOf(NestedLazyData::class)] + public Lazy|Collection $nestedLazyCollection; + }; + + $dataClass->lazyCollection = Lazy::create(fn (): Collection => collect([ + SimpleData::from('A'), + SimpleData::from('B'), + ])); + + $dataClass->nestedLazyCollection = Lazy::create(fn (): Collection => collect([ + NestedLazyData::from('C'), + NestedLazyData::from('D'), + ])); + + $this->assertSame([], $dataClass->toArray()); + + $this->assertSame([ + 'lazyCollection' => [ + ['string' => 'A'], + ['string' => 'B'], + ], + ], $dataClass->include('lazyCollection')->toArray()); + + $this->assertSame([ + 'lazyCollection' => [ + ['string' => 'A'], + ['string' => 'B'], + ], + + 'nestedLazyCollection' => [ + ['simple' => ['string' => 'C']], + ['simple' => ['string' => 'D']], + ], + ], $dataClass->include('lazyCollection', 'nestedLazyCollection.simple')->toArray()); + } + + public function testCanWorkWithLazyHypervelDataPaginators(): void + { + $dataClass = new class extends Data { + #[DataCollectionOf(SimpleData::class)] + public Lazy|Collection $lazyCollection; + + #[DataCollectionOf(NestedLazyData::class)] + public Lazy|Collection $nestedLazyCollection; + }; + + $dataClass->lazyCollection = Lazy::create(fn (): LengthAwarePaginator => new LengthAwarePaginator([ + SimpleData::from('A'), + SimpleData::from('B'), + ], total: 15, perPage: 15)); + + $dataClass->nestedLazyCollection = Lazy::create(fn (): LengthAwarePaginator => new LengthAwarePaginator([ + NestedLazyData::from('C'), + NestedLazyData::from('D'), + ], total: 15, perPage: 15)); + + $this->assertSame([], $dataClass->toArray()); + + $array = $dataClass->include('lazyCollection')->toArray(); + + $this->assertSame([ + ['string' => 'A'], + ['string' => 'B'], + ], $array['lazyCollection']['data']); + $this->assertArrayNotHasKey('nestedLazyCollection', $array); + + $array = $dataClass->include('lazyCollection', 'nestedLazyCollection.simple')->toArray(); + + $this->assertSame([ + ['string' => 'A'], + ['string' => 'B'], + ], $array['lazyCollection']['data']); + $this->assertSame([ + ['simple' => ['string' => 'C']], + ['simple' => ['string' => 'D']], + ], $array['nestedLazyCollection']['data']); + } + + public function testPartialsAreAlwaysResetWhenTransformingAgain(): void + { + $dataClass = new class(Lazy::create(fn (): NestedLazyData => NestedLazyData::from('Hello World'))) extends Data { + /** + * Create a data object with a lazy nested object. + */ + public function __construct( + public Lazy|NestedLazyData $nested + ) { + } + }; + + $this->assertSame([ + 'nested' => ['simple' => ['string' => 'Hello World']], + ], $dataClass->include('nested.simple')->toArray()); + + $this->assertSame([ + 'nested' => [], + ], $dataClass->include('nested')->toArray()); + + $this->assertSame([], $dataClass->include()->toArray()); + } + + public function testCanDefinePermanentPartialsWhichWillAlwaysBeUsed(): void + { + $dataClass = new class(Lazy::create(fn (): NestedLazyData => NestedLazyData::from('Hello World')), Lazy::create(fn (): string => 'Hello World')) extends Data { + /** + * Create a data object with lazy nested and string values. + */ + public function __construct( + public Lazy|NestedLazyData $nested, + public Lazy|string $string, + ) { + } + + /** + * Get the permanently included properties. + */ + protected function includeProperties(): array + { + return [ + 'nested.simple', + ]; + } + }; + + $this->assertSame([ + 'nested' => ['simple' => ['string' => 'Hello World']], + ], $dataClass->toArray()); + + $this->assertSame([ + 'nested' => ['simple' => ['string' => 'Hello World']], + 'string' => 'Hello World', + ], $dataClass->include('string')->toArray()); + + $this->assertSame([ + 'nested' => ['simple' => ['string' => 'Hello World']], + ], $dataClass->toArray()); + } + + #[DataProvider('permanentPartialCases')] + public function testCanDefinePermanentPartialsUsingFunctionCall( + Data $data, + Closure $temporaryPartial, + Closure $permanentPartial, + array $expectedFullPayload, + array $expectedPartialPayload + ): void { + $data = $temporaryPartial($data); + + $this->assertSame($expectedPartialPayload, $data->toArray()); + $this->assertSame($expectedFullPayload, $data->toArray()); + + $data = $permanentPartial($data); + + $this->assertSame($expectedPartialPayload, $data->toArray()); + $this->assertSame($expectedPartialPayload, $data->toArray()); + } + + /** + * Provide each partial with its temporary and permanent forms and the payloads they produce. + */ + public static function permanentPartialCases(): iterable + { + yield [ + new LazyData( + Lazy::create(fn (): string => 'Rick Astley'), + ), // data + fn (LazyData $data): LazyData => $data->include('name'), // temporaryPartial + fn (LazyData $data): LazyData => $data->includePermanently('name'), // permanentPartial + [], // expectedFullPayload + ['name' => 'Rick Astley'], // expectedPartialPayload + ]; + + yield [ + new LazyData( + Lazy::create(fn (): string => 'Rick Astley')->defaultIncluded(), + ), // data + fn (LazyData $data): LazyData => $data->exclude('name'), // temporaryPartial + fn (LazyData $data): LazyData => $data->excludePermanently('name'), // permanentPartial + ['name' => 'Rick Astley'], // expectedFullPayload + [], // expectedPartialPayload + ]; + + yield [ + new MultiData( + 'Rick Astley', + 'Never gonna give you up', // data + ), + fn (MultiData $data): MultiData => $data->only('first'), // temporaryPartial + fn (MultiData $data): MultiData => $data->onlyPermanently('first'), // permanentPartial + ['first' => 'Rick Astley', 'second' => 'Never gonna give you up'], // expectedFullPayload + ['first' => 'Rick Astley'], // expectedPartialPayload + ]; + + yield [ + new MultiData( + 'Rick Astley', + 'Never gonna give you up', // data + ), + fn (MultiData $data): MultiData => $data->except('first'), // temporaryPartial + fn (MultiData $data): MultiData => $data->exceptPermanently('first'), // permanentPartial + ['first' => 'Rick Astley', 'second' => 'Never gonna give you up'], // expectedFullPayload + ['second' => 'Never gonna give you up'], // expectedPartialPayload + ]; + } + + public function testCanSetPartialsOnANestedDataObjectAndTheseWillBeRespected(): void + { + $collection = new DataCollection(TestMultiLazyNestedDataWithObjectAndCollection::class, [ + new TestMultiLazyNestedDataWithObjectAndCollection( + NestedLazyData::from('A'), + [ + NestedLazyData::from('B1')->include('simple'), + NestedLazyData::from('B2'), + ], + ), + new TestMultiLazyNestedDataWithObjectAndCollection( + NestedLazyData::from('C'), + [ + NestedLazyData::from('D1'), + NestedLazyData::from('D2')->include('simple.string'), + ], + ), + ]); + + $collection->include('nested.simple'); + + $data = new class(Lazy::create(fn (): DataCollection => $collection)) extends Data { + /** + * Create a data object with a lazy data collection. + */ + public function __construct( + #[DataCollectionOf(TestMultiLazyNestedDataWithObjectAndCollection::class)] + public Lazy|DataCollection $collection + ) { + } + }; + + $this->assertSame([ + 'collection' => [ + [ + 'nested' => [ + 'simple' => [ + 'string' => 'A', + ], + ], + 'nestedCollection' => [ + [ + 'simple' => [ + 'string' => 'B1', + ], + ], + [], + ], + ], + [ + 'nested' => [ + 'simple' => [ + 'string' => 'C', + ], + ], + 'nestedCollection' => [ + [], + [ + 'simple' => [ + 'string' => 'D2', + ], + ], + ], + ], + ], + ], $data->include('collection')->toArray()); + } + + /** + * @param list $expectedPartials the include paths the request resolves to + */ + #[DataProvider('requestPartialCases')] + public function testWillCheckIfPartialsAreValidAsRequestPartials( + ?array $lazyDataAllowedIncludes, + ?array $dataAllowedIncludes, + ?string $includes, + array $expectedPartials, + array $expectedResponse + ): void { + LazyData::setAllowedIncludes($lazyDataAllowedIncludes); + RequestPartialsCheckData::$allowedIncludes = $dataAllowedIncludes; + + $data = new RequestPartialsCheckData( + Lazy::create(fn (): string => 'Hello'), + Lazy::create(fn (): LazyData => LazyData::from('Hello')), + Lazy::create(fn (): array => LazyData::collect(['Hello', 'World'])), + ); + + $request = Request::create('/', 'GET', $includes === null ? [] : ['include' => $includes]); + + // Hypervel applies the allowed paths to the transformation, rather than returning upstream's PartialsCollection. + $context = $this->app->make(RequestQueryStringPartialsResolver::class) + ->resolve($data, $request, TransformationContextFactory::create()) + ->get($data); + + $this->assertSame($expectedPartials, array_map( + static fn (PartialDefinition $definition): string => $definition->path, + $context->partialDefinitions['include'] ?? [], + )); + $this->assertSame($expectedResponse, $data->toResponse($request)->getData(true)); + } + + /** + * Provide the allowed includes, the requested includes, and the resolved paths and response. + */ + public static function requestPartialCases(): iterable + { + yield 'disallowed property inclusion' => [ + [], // lazyDataAllowedIncludes + [], // dataAllowedIncludes + 'property', // includes + [], // expectedPartials + [], // expectedResponse + ]; + + yield 'allowed property inclusion' => [ + [], // lazyDataAllowedIncludes + ['property'], // dataAllowedIncludes + 'property', // includes + ['property'], // expectedPartials + [ + 'property' => 'Hello', + ], // expectedResponse + ]; + + yield 'allowed data property inclusion without nesting' => [ + [], // lazyDataAllowedIncludes + ['nested'], // dataAllowedIncludes + 'nested.name', // includes + ['nested'], // expectedPartials + [ + 'nested' => [], + ], // expectedResponse + ]; + + yield 'allowed data property inclusion with nesting' => [ + ['name'], // lazyDataAllowedIncludes + ['nested'], // dataAllowedIncludes + 'nested.name', // includes + ['nested.name'], // expectedPartials + [ + 'nested' => [ + 'name' => 'Hello', + ], + ], // expectedResponse + ]; + + yield 'allowed data collection property inclusion without nesting' => [ + [], // lazyDataAllowedIncludes + ['collection'], // dataAllowedIncludes + 'collection.name', // includes + ['collection'], // expectedPartials + [ + 'collection' => [ + [], + [], + ], + ], // expectedResponse + ]; + + yield 'allowed data collection property inclusion with nesting' => [ + ['name'], // lazyDataAllowedIncludes + ['collection'], // dataAllowedIncludes + 'collection.name', // includes + ['collection.name'], // expectedPartials + [ + 'collection' => [ + ['name' => 'Hello'], + ['name' => 'World'], + ], + ], // expectedResponse + ]; + + yield 'allowed nested data property inclusion without defining allowed includes on nested' => [ + null, // lazyDataAllowedIncludes + ['nested'], // dataAllowedIncludes + 'nested.name', // includes + ['nested.name'], // expectedPartials + [ + 'nested' => [ + 'name' => 'Hello', + ], + ], // expectedResponse + ]; + + yield 'allowed all nested data property inclusion without defining allowed includes on nested' => [ + null, // lazyDataAllowedIncludes + ['nested'], // dataAllowedIncludes + 'nested.*', // includes + ['nested.*'], // expectedPartials + [ + 'nested' => [ + 'name' => 'Hello', + ], + ], // expectedResponse + ]; + + yield 'disallowed all nested data property inclusion ' => [ + [], // lazyDataAllowedIncludes + ['nested'], // dataAllowedIncludes + 'nested.*', // includes + ['nested'], // expectedPartials + [ + 'nested' => [], + ], // expectedResponse + ]; + + yield 'multi property inclusion' => [ + null, // lazyDataAllowedIncludes + ['nested', 'property'], // dataAllowedIncludes + 'nested.*,property', // includes + ['nested.*', 'property'], // expectedPartials + [ + 'property' => 'Hello', + 'nested' => [ + 'name' => 'Hello', + ], + ], // expectedResponse + ]; + + yield 'without property inclusion' => [ + null, // lazyDataAllowedIncludes + ['nested', 'property'], // dataAllowedIncludes + null, // includes + [], // expectedPartials + [], // expectedResponse + ]; + + yield 'with invalid partial definition' => [ + null, // lazyDataAllowedIncludes + null, // dataAllowedIncludes + '', // includes + [], // expectedPartials + [], // expectedResponse + ]; + + yield 'with non existing field' => [ + [], // lazyDataAllowedIncludes + [], // dataAllowedIncludes + 'non-existing', // includes + [], // expectedPartials + [], // expectedResponse + ]; + + yield 'with non existing nested field' => [ + [], // lazyDataAllowedIncludes + [], // dataAllowedIncludes + 'non-existing.still-non-existing', // includes + [], // expectedPartials + [], // expectedResponse + ]; + + yield 'with non allowed nested field' => [ + [], // lazyDataAllowedIncludes + [], // dataAllowedIncludes + 'nested.name', // includes + [], // expectedPartials + [], // expectedResponse + ]; + + yield 'with non allowed nested all' => [ + [], // lazyDataAllowedIncludes + [], // dataAllowedIncludes + 'nested.*', // includes + [], // expectedPartials + [], // expectedResponse + ]; + } + + public function testCanCombineRequestAndManualIncludes(): void + { + $dataclass = new UnrestrictedMultiLazyData( + Lazy::create(fn (): string => 'Rick Astley'), + Lazy::create(fn (): string => 'Never gonna give you up'), + Lazy::create(fn (): int => 1986), + ); + + $data = $dataclass->include('name')->toResponse(Request::create('/', 'GET', [ + 'include' => 'artist', + ]))->getData(true); + + $this->assertSame([ + 'artist' => 'Rick Astley', + 'name' => 'Never gonna give you up', + ], $data); + } + + #[DataProvider('requestIncludeFormats')] + public function testHandlesParsingIncludesFromRequestInDifferentFormats(array $input, array $expected): void + { + $dataclass = new WildcardMultiLazyData( + Lazy::create(fn (): string => 'Rick Astley'), + Lazy::create(fn (): string => 'Never gonna give you up'), + Lazy::create(fn (): int => 1986), + ); + + $data = $dataclass->toResponse(Request::create('/', 'GET', $input))->getData(true); + + $this->assertSame($expected, array_keys($data)); + } + + /** + * Provide the request include formats and the keys they select. + */ + public static function requestIncludeFormats(): iterable + { + yield 'input as array' => [ + ['include' => ['artist', 'name']], // input + ['artist', 'name'], // expected + ]; + + yield 'input as comma separated' => [ + ['include' => 'artist,name'], // input + ['artist', 'name'], // expected + ]; + } + + public function testHandlesPartialsWhenNotTransformingValuesByCopyingThemToLazyNestedDataObjects(): void + { + $dataClass = new class extends Data { + public Lazy|NestedLazyData $nested; + + /** + * Create a data object with a lazy nested object. + */ + public function __construct() + { + $this->nested = Lazy::create(fn (): NestedLazyData => NestedLazyData::from('Rick Astley')); + } + }; + + $this->assertSame([ + 'nested' => [ + 'simple' => [ + 'string' => 'Rick Astley', + ], + ], + ], $dataClass->include('nested.simple')->toArray()); + + $nested = $dataClass->include('nested.simple')->all()['nested']; + + $this->assertInstanceOf(NestedLazyData::class, $nested); + $this->assertSame([ + 'simple' => [ + 'string' => 'Rick Astley', + ], + ], $nested->toArray()); + } + + public function testHandlesPartialsWhenNotTransformingValuesByCopyingThemToLazyDataCollections(): void + { + $dataClass = new class extends Data { + public Lazy|DataCollection $collection; + + /** + * Create a data object with a lazy data collection. + */ + public function __construct() + { + $this->collection = Lazy::create(fn (): DataCollection => NestedLazyData::collect([ + 'Rick Astley', + 'Jon Bon Jovi', + ], DataCollection::class)); + } + }; + + $this->assertSame([ + 'collection' => [ + [ + 'simple' => [ + 'string' => 'Rick Astley', + ], + ], + [ + 'simple' => [ + 'string' => 'Jon Bon Jovi', + ], + ], + ], + ], $dataClass->include('collection.simple')->toArray()); + + $nested = $dataClass->include('collection.simple')->all()['collection']; + + $this->assertInstanceOf(DataCollection::class, $nested); + $this->assertSame([ + [ + 'simple' => [ + 'string' => 'Rick Astley', + ], + ], + [ + 'simple' => [ + 'string' => 'Jon Bon Jovi', + ], + ], + ], $nested->toArray()); + } + + public function testHandlesPartialsWhenNotTransformingValuesByCopyingThemToALazyArrayOfDataObjects(): void + { + $dataClass = new class extends Data { + /** @var array */ + public Lazy|array $collection; + + /** + * Create a data object with a lazy array of data objects. + */ + public function __construct() + { + $this->collection = Lazy::create(fn (): array => NestedLazyData::collect([ + 'Rick Astley', + 'Jon Bon Jovi', + ])); + } + }; + + $this->assertSame([ + 'collection' => [ + [ + 'simple' => [ + 'string' => 'Rick Astley', + ], + ], + [ + 'simple' => [ + 'string' => 'Jon Bon Jovi', + ], + ], + ], + ], $dataClass->include('collection.simple')->toArray()); + + $nested = $dataClass->include('collection.simple')->all()['collection']; + + $this->assertSame([ + [ + 'simple' => [ + 'string' => 'Rick Astley', + ], + ], + [ + 'simple' => [ + 'string' => 'Jon Bon Jovi', + ], + ], + ], array_map(fn (NestedLazyData $data): array => $data->toArray(), $nested)); + } + + public function testHandlesPartialsWhenNotTransformingValuesByCopyingThemToLazyNestedDataObjectsInDataCollections(): void + { + $dataClass = new class extends Data { + public DataCollection $collection; + + /** + * Create a data object with a data collection. + */ + public function __construct() + { + $this->collection = NestedLazyData::collect([ + 'Rick Astley', + 'Jon Bon Jovi', + ], DataCollection::class); + } + }; + + $this->assertSame([ + 'collection' => [ + [ + 'simple' => [ + 'string' => 'Rick Astley', + ], + ], + [ + 'simple' => [ + 'string' => 'Jon Bon Jovi', + ], + ], + ], + ], $dataClass->include('collection.simple')->toArray()); + + $nested = $dataClass->include('collection.simple')->all()['collection']; + + $this->assertInstanceOf(DataCollection::class, $nested); + $this->assertSame([ + [ + 'simple' => [ + 'string' => 'Rick Astley', + ], + ], + [ + 'simple' => [ + 'string' => 'Jon Bon Jovi', + ], + ], + ], $nested->toArray()); + } + + public function testHandlesParsingExceptFromRequestWithMappedOutputName(): void + { + $dataclass = SimpleDataWithMappedOutputName::from([ + 'id' => 1, + 'amount' => 1000, + 'any_string' => 'test', + 'child' => SimpleChildDataWithMappedOutputName::from([ + 'id' => 2, + 'amount' => 2000, + ]), + ]); + + $request = Request::create('/', 'GET', ['except' => ['paid_amount', 'any_string', 'child.child_amount']]); + + $data = $dataclass->toResponse($request)->getData(true); + + $this->assertSame([ + 'id' => 1, + 'child' => [ + 'id' => 2, + ], + ], $data); + } + + public function testHandlesCircularDependencies(): void + { + $dataClass = new CircData( + 'test', + new UlarData( + 'test', + new CircData('test', null) + ) + ); + + $data = $dataClass->toResponse(Request::create('/'))->getData(true); + + $this->assertSame([ + 'string' => 'test', + 'ular' => [ + 'string' => 'test', + 'circ' => [ + 'string' => 'test', + 'ular' => null, + ], + ], + ], $data); + // Not really a test with expectation, we just want to check we don't end up in an infinite loop + } +} + +class TestMultiLazyNestedDataWithObjectAndCollection extends Data +{ + /** + * Create a fixture with a lazy nested object and a lazy nested array. + */ + public function __construct( + public Lazy|NestedLazyData $nested, + #[DataCollectionOf(NestedLazyData::class)] + public Lazy|array $nestedCollection, + ) { + } +} + +class RequestPartialsCheckData extends Data +{ + public static ?array $allowedIncludes = null; + + /** + * Create a fixture with a lazy property, nested object and collection. + */ + public function __construct( + public Lazy|string $property, + public Lazy|LazyData $nested, + #[DataCollectionOf(LazyData::class)] + public Lazy|array $collection, + ) { + } + + /** + * Get the includes a request may ask for. + */ + public static function allowedRequestIncludes(): ?array + { + return static::$allowedIncludes; + } +} + +class UnrestrictedMultiLazyData extends MultiLazyData +{ + /** + * Allow every request include. + */ + public static function allowedRequestIncludes(): ?array + { + return null; + } +} + +class WildcardMultiLazyData extends MultiLazyData +{ + /** + * Allow every request include through a wildcard. + */ + public static function allowedRequestIncludes(): ?array + { + return ['*']; + } +} + +#[MapName(SnakeCaseMapper::class)] +class SimpleDataWithMappedOutputName extends Data +{ + /** + * Create a fixture with mapped output names. + */ + public function __construct( + public int $id, + #[MapOutputName('paid_amount')] + public float $amount, + public string $anyString, + public SimpleChildDataWithMappedOutputName $child + ) { + } + + /** + * Get the except selections a request may ask for. + */ + public static function allowedRequestExcept(): ?array + { + return [ + 'amount', + 'anyString', + 'child', + ]; + } +} diff --git a/tests/Data/PipelineTest.php b/tests/Data/PipelineTest.php new file mode 100644 index 0000000000..cb5e495839 --- /dev/null +++ b/tests/Data/PipelineTest.php @@ -0,0 +1,61 @@ + 'Freek', + 'line_1' => '123 Sesame St', + 'city' => 'New York', + 'state' => 'NJ', + 'zipcode' => '10010', + ]); + + $this->assertSame( + ['name' => 'Freek', 'address' => '123 Sesame St,New York,NJ,10010'], + $instance->toArray(), + ); + } +} diff --git a/tests/Data/RequestTest.php b/tests/Data/RequestTest.php new file mode 100644 index 0000000000..ee4a1baeae --- /dev/null +++ b/tests/Data/RequestTest.php @@ -0,0 +1,223 @@ + $data->string]; + }); + } + + public function testCanPassValidation(): void + { + Route::post('/example-route', function (SimpleData $data): array { + return ['given' => $data->string]; + }); + + $this->postJson('/example-route', [ + 'string' => 'Hello', + ]) + ->assertOk() + ->assertJson(['given' => 'Hello']); + } + + public function testCanReturnsA201ResponseCodeForPostRequests(): void + { + Route::post('/example-route', function (): SimpleData { + return new SimpleData(request()->input('string')); + }); + + // Responses use 200 for every method; Spatie returns 201 for every POST (README). + $this->postJson('/example-route', [ + 'string' => 'Hello', + ]) + ->assertOk() + ->assertJson(['string' => 'Hello']); + } + + public function testIsPossibleToOverwriteTheStatusResponseCode(): void + { + Route::post('/example-route', function (): SimpleData { + // withResponse() replaces Spatie's calculateResponseStatus() override (README). + return new class(request()->input('string')) extends SimpleData { + /** + * Customize the outgoing response status. + */ + public function withResponse(Request $request, JsonResponse $response): void + { + $response->setStatusCode(301); + } + }; + }); + + $this->postJson('/example-route', [ + 'string' => 'Hello', + ]) + ->assertStatus(301) + ->assertJson(['string' => 'Hello']); + } + + public function testCanFailValidation(): void + { + Route::post('/example-route', function (SimpleDataWithExplicitValidationRuleAttributeData $data): array { + return ['email' => $data->email]; + }); + + $this->postJson('/example-route', [ + 'email' => 'Hello', + ]) + ->assertStatus(422) + ->assertJsonValidationErrors([ + 'email' => __('validation.email', ['attribute' => 'email']), + ]); + } + + public function testAlwaysValidatesRequestsWhenPassedToTheFromMethod(): void + { + try { + SimpleData::from(new Request); + } catch (ValidationException $exception) { + $this->assertSame([ + 'string' => [__('validation.required', ['attribute' => 'string'])], + ], $exception->errors()); + + return; + } + + $this->fail('We should not end up here'); + } + + public function testCanCheckForAuthorization(): void + { + Route::post('/example-route', function (TestDataWithAuthorizationFailure $data): void { + }); + + $this->postJson('/example-route', [ + 'string' => 'test', + ])->assertStatus(403); + } + + public function testCanManuallyOverrideHowTheDataObjectWillBeConstructed(): void + { + Route::post('/other-route', function (TestOverrideableDataFromRequest $data): array { + return ['name' => $data->name]; + }); + + $this->postJson('/other-route', [ + 'first_name' => 'Rick', + 'last_name' => 'Astley', + ]) + ->assertOk() + ->assertJson(['name' => 'Rick Astley']); + } + + public function testCanBuildAuthorizeParametersFromTheContainer(): void + { + $this->app->bind(SomeDependency::class, fn (): SomeDependency => new SomeDependency('Sesame')); + + Route::post('/route-with-authorization-dependencies', function (AuthorizeFromContainerRequest $data): array { + return ['name' => $data->name, 'street' => AuthorizeFromContainerRequest::$street]; + }); + + $this->postJson('/route-with-authorization-dependencies', [ + 'name' => 'Rick Astley', + ]) + ->assertOk() + ->assertJson(['name' => 'Rick Astley', 'street' => 'Sesame']); + } +} + +class TestDataWithAuthorizationFailure extends Data +{ + public string $string; + + /** + * Deny every request. + */ + public static function authorize(): bool + { + return false; + } +} + +class TestOverrideableDataFromRequest extends Data +{ + /** + * Create the data object from a full name. + */ + public function __construct( + #[WithoutValidation] + public string $name + ) { + } + + /** + * Create the data object from separate name fields. + */ + public static function fromRequest(Request $request): static + { + return new static("{$request->input('first_name')} {$request->input('last_name')}"); + } +} + +class SomeDependency +{ + /** + * Create the dependency. + */ + public function __construct(public string $street) + { + } +} + +class AuthorizeFromContainerRequest extends Data +{ + public static string $street; + + /** + * Create the data object. + */ + public function __construct(public string $name) + { + } + + /** + * Authorize the request with a container-resolved dependency. + */ + public static function authorize(SomeDependency $dependency): bool + { + self::$street = $dependency->street; + + return $dependency->street === 'Sesame'; + } +} diff --git a/tests/Data/Resolvers/DataClassFromValidationPayloadResolverTest.php b/tests/Data/Resolvers/DataClassFromValidationPayloadResolverTest.php new file mode 100644 index 0000000000..fc1749fcef --- /dev/null +++ b/tests/Data/Resolvers/DataClassFromValidationPayloadResolverTest.php @@ -0,0 +1,59 @@ +assertSame(['string'], array_keys(SimpleData::getValidationRules(['string' => 'Hello']))); + } + + public function testReturnsTheMorphedDataClassBasedOnThePayload(): void + { + $rules = AbstractPropertyMorphableData::getValidationRules(['variant' => 'a', 'a' => 'test', 'enum' => 'foo']); + + $this->assertSame(['a', 'enum', 'variant'], array_keys($rules)); + } + + public function testReturnsTheAbstractDataClassWhenMorphCannotBeResolved(): void + { + $rules = AbstractPropertyMorphableData::getValidationRules(['variant' => 'unknown']); + + $this->assertSame(['variant'], array_keys($rules)); + $this->assertInstanceOf(EnsurePropertyMorphable::class, $rules['variant'][array_key_last($rules['variant'])]); + } + + public function testResolvesTheMorphedDataClassFromANestedPath(): void + { + $rules = NestedMorphableData::getValidationRules(['nested' => ['variant' => 'a', 'a' => 'test', 'enum' => 'foo']]); + + $this->assertSame(['nested', 'nested.a', 'nested.enum', 'nested.variant'], array_keys($rules)); + } +} + +class NestedMorphableData extends Data +{ + public AbstractPropertyMorphableData $nested; +} diff --git a/tests/Data/Resolvers/DataMorphClassResolverTest.php b/tests/Data/Resolvers/DataMorphClassResolverTest.php new file mode 100644 index 0000000000..57a5fe5377 --- /dev/null +++ b/tests/Data/Resolvers/DataMorphClassResolverTest.php @@ -0,0 +1,174 @@ +assertSame(SimpleData::class, SimpleData::from(['string' => 'Hello'])::class); + } + + public function testReturnsNullForNonPropertyMorphableClasses(): void + { + $class = new class extends Data { + public string $name; + }; + + $this->assertSame($class::class, $class::from(['name' => 'Hello'])::class); + } + + public function testCanResolveMorphClassBasedOnProperties(): void + { + $this->assertInstanceOf( + TestMorphableUserData::class, + TestAbstractMorphableData::from(['type' => 'user']), + ); + } + + public function testCanResolveMorphClassWithMappedInputNames(): void + { + $this->assertInstanceOf( + TestMorphablePostData::class, + TestAbstractMorphableDataWithMappedInputName::from(['type_for_morph' => 'post']), + ); + } + + public function testCanResolveMorphClassWithBackedEnumType(): void + { + $this->assertInstanceOf( + TestMorphableFooData::class, + TestAbstractMorphableDataWithBackedEnum::from(['type' => DummyBackedEnum::FOO]), + ); + } + + public function testCanResolveMorphClassWithBackedEnumTypeUsingDefaultValue(): void + { + $this->assertInstanceOf( + TestMorphableDefaultBooData::class, + TestAbstractMorphableDataWithDefaultValue::from([]), + ); + } + + public function testCanResolveMorphClassWithBackedEnumTypeIgnoringDefaultValue(): void + { + $this->assertInstanceOf( + TestMorphableNullableFooData::class, + TestAbstractMorphableDataWithNullableBackedEnum::from(['type' => DummyBackedEnum::FOO]), + ); + } +} + +abstract class TestAbstractMorphableData extends Data implements PropertyMorphableData +{ + #[PropertyForMorph] + public string $type; + + /** + * Get the subclass for the given properties. + */ + public static function morph(array $properties): ?string + { + return $properties['type'] === 'user' ? TestMorphableUserData::class : null; + } +} + +class TestMorphableUserData extends TestAbstractMorphableData +{ +} + +abstract class TestAbstractMorphableDataWithMappedInputName extends Data implements PropertyMorphableData +{ + #[PropertyForMorph] + #[MapInputName('type_for_morph')] + public string $type; + + /** + * Get the subclass for the given properties. + */ + public static function morph(array $properties): ?string + { + return $properties['type'] === 'post' ? TestMorphablePostData::class : null; + } +} + +class TestMorphablePostData extends TestAbstractMorphableDataWithMappedInputName +{ +} + +abstract class TestAbstractMorphableDataWithBackedEnum extends Data implements PropertyMorphableData +{ + #[PropertyForMorph] + public DummyBackedEnum $type; + + /** + * Get the subclass for the given properties. + */ + public static function morph(array $properties): ?string + { + return $properties['type'] === DummyBackedEnum::FOO ? TestMorphableFooData::class : null; + } +} + +class TestMorphableFooData extends TestAbstractMorphableDataWithBackedEnum +{ +} + +abstract class TestAbstractMorphableDataWithDefaultValue extends Data implements PropertyMorphableData +{ + #[PropertyForMorph] + public DummyBackedEnum $type = DummyBackedEnum::BOO; + + /** + * Get the subclass for the given properties. + */ + public static function morph(array $properties): ?string + { + return $properties['type'] === DummyBackedEnum::BOO ? TestMorphableDefaultBooData::class : null; + } +} + +class TestMorphableDefaultBooData extends TestAbstractMorphableDataWithDefaultValue +{ +} + +abstract class TestAbstractMorphableDataWithNullableBackedEnum extends Data implements PropertyMorphableData +{ + #[PropertyForMorph] + public ?DummyBackedEnum $type = null; + + /** + * Get the subclass for the given properties. + */ + public static function morph(array $properties): ?string + { + return $properties['type'] === DummyBackedEnum::FOO ? TestMorphableNullableFooData::class : null; + } +} + +class TestMorphableNullableFooData extends TestAbstractMorphableDataWithNullableBackedEnum +{ +} diff --git a/tests/Data/Resolvers/VisibleDataFieldsResolverTest.php b/tests/Data/Resolvers/VisibleDataFieldsResolverTest.php new file mode 100644 index 0000000000..d410abe121 --- /dev/null +++ b/tests/Data/Resolvers/VisibleDataFieldsResolverTest.php @@ -0,0 +1,1126 @@ + $this->getRefreshConnection(), + '--realpath' => true, + '--path' => __DIR__ . '/../Fixtures/Migrations', + ]; + } + + public function testWillHideHiddenFields(): void + { + $dataClass = new class extends Data { + public string $visible = 'visible'; + + #[Hidden] + public string $hidden = 'hidden'; + }; + + $this->assertSame([ + 'visible' => 'visible', + ], $dataClass->toArray()); + } + + public function testWillHideFieldsWhichAreUninitialized(): void + { + $dataClass = new class extends Data { + public string $visible = 'visible'; + + public Optional|string $optional; + }; + + $this->assertSame([ + 'visible' => 'visible', + ], $dataClass->toArray()); + } + + public function testWillHideOptionalValues(): void + { + $dataClass = new class extends Data { + public Lazy|string|Optional $lazyOptional; + + /** + * Create a data object with optional values. + */ + public function __construct( + public string $visible = 'visible', + public Optional|string $optional = new Optional, + ) { + $this->lazyOptional = Lazy::create(fn (): Optional => new Optional); + } + }; + + $this->assertSame([ + 'visible' => 'visible', + ], $dataClass->toArray()); + + $this->assertSame([ + 'visible' => 'visible', + ], $dataClass->include('lazyOptional')->toArray()); + } + + public function testWillAlwaysShowNonLazyValuesWhenNoOnlyOrExcludeOperationsArePerformedOnIt(): void + { + $dataClass = new class extends Data { + /** + * Create a data object with a visible lazy-capable value. + */ + public function __construct( + public string $visible = 'visible', + public Lazy|string $lazy = 'lazy but visible', + ) { + } + }; + + $this->assertSame([ + 'visible' => 'visible', + 'lazy' => 'lazy but visible', + ], $dataClass->toArray()); + } + + public function testCanHaveLazyBehaviourBasedUponACondition(): void + { + $dataClass = new class extends Data { + /** + * Create a data object with an optional name. + */ + public function __construct( + public string|Lazy|null $name = null + ) { + } + + /** + * Create a data object whose name is included only for Ruben. + */ + public static function create(string $name): static + { + return new self( + Lazy::when(fn (): bool => $name === 'Ruben', fn (): string => $name) + ); + } + }; + + $this->assertSame(['name' => null], $dataClass->toArray()); + $this->assertSame([], $dataClass::create('Freek')->toArray()); + $this->assertSame(['name' => 'Ruben'], $dataClass::create('Ruben')->toArray()); + } + + public function testIsImpossibleToLazyIncludeConditionalLazyProperties(): void + { + $dataClass = new class extends Data { + /** + * Create a data object with an optional name. + */ + public function __construct( + public string|Lazy|null $name = null + ) { + } + + /** + * Create a data object whose name is included only for Ruben. + */ + public static function create(string $name): static + { + return new self( + Lazy::when(fn (): bool => $name === 'Ruben', fn (): string => $name) + ); + } + }; + + $this->assertSame([], $dataClass::create('Freek')->include('name')->toArray()); + } + + public function testCanIncludeDataBasedUponRelationsBeingLoaded(): void + { + $model = FakeNestedModel::factory()->create(); + + $transformed = FakeNestedModelData::createWithLazyWhenLoaded($model)->all(); + + $this->assertArrayNotHasKey('fake_model', $transformed); + + $transformed = FakeNestedModelData::createWithLazyWhenLoaded($model->load('fakeModel'))->all(); + + $this->assertArrayHasKey('fake_model', $transformed); + $this->assertInstanceOf(FakeModelData::class, $transformed['fake_model']); + } + + public function testCanIncludeDataBasedUponRelationsLoadedWhenTheyAreNull(): void + { + $model = FakeNestedModel::factory(['fake_model_id' => null])->create(); + + $transformed = FakeNestedModelData::createWithLazyWhenLoaded($model)->all(); + + $this->assertArrayNotHasKey('fake_model', $transformed); + + $transformed = FakeNestedModelData::createWithLazyWhenLoaded($model->load('fakeModel'))->all(); + + $this->assertArrayHasKey('fake_model', $transformed); + $this->assertNull($transformed['fake_model']); + } + + public function testCanIncludeLazyDataByDefault(): void + { + $dataClass = new class('') extends Data { + /** + * Create a data object with a name. + */ + public function __construct( + public string|Lazy $name + ) { + } + + /** + * Create a data object whose lazy name is included by default. + */ + public static function create(string $name): static + { + return new self( + Lazy::create(fn (): string => $name)->defaultIncluded() + ); + } + }; + + $this->assertSame(['name' => 'Ruben'], $dataClass::create('Ruben')->toArray()); + } + + public function testAlwaysTransformsLazyInertiaDataToInertiaLazyProps(): void + { + // Upstream skips this case until Inertia supports Laravel 12; Hypervel's Inertia adapter is available. + $dataClass = new class extends Data { + /** + * Create a data object with an optional name. + */ + public function __construct( + public string|InertiaLazy|null $name = null + ) { + } + + /** + * Create a data object with an Inertia lazy name. + */ + public static function create(string $name): static + { + return new self( + Lazy::inertia(fn (): string => $name) + ); + } + }; + + $this->assertInstanceOf(OptionalProp::class, $dataClass::create('Freek')->toArray()['name']); + } + + public function testAlwaysTransformsDeferredInertiaDataToInertiaDeferredProps(): void + { + $dataClass = new class extends Data { + /** + * Create a data object with an optional name. + */ + public function __construct( + public string|InertiaDeferred|null $name = null + ) { + } + + /** + * Create a data object with an Inertia deferred name. + */ + public static function create(string $name): static + { + return new self( + Lazy::inertiaDeferred(Inertia::defer(fn (): string => $name)) + ); + } + }; + + $this->assertInstanceOf(DeferProp::class, $dataClass::create('Freek')->toArray()['name']); + } + + public function testAlwaysTransformsClosureLazyIntoClosuresForInertia(): void + { + // Upstream skips this case until Inertia supports Laravel 12; it needs no Inertia support here. + $dataClass = new class extends Data { + /** + * Create a data object with an optional name. + */ + public function __construct( + public string|ClosureLazy|null $name = null + ) { + } + + /** + * Create a data object with a closure lazy name. + */ + public static function create(string $name): static + { + return new self( + Lazy::closure(fn (): string => $name) + ); + } + }; + + $this->assertInstanceOf(Closure::class, $dataClass::create('Freek')->toArray()['name']); + } + + #[DataProvider('partialOperationProvider')] + public function testWillFailGracefullyWhenANestedFieldDoesNotExist(string $operation): void + { + $this->expectException(CannotPerformPartialOnDataField::class); + $this->expectExceptionMessage( + "Cannot apply the [{$operation}] partial to unknown data property [certainly-not-simple]", + ); + + (new MissingNestedFieldData)->{$operation}('certainly-not-simple.string')->toArray(); + } + + /** + * Provide the partial operations that check nested paths. + * + * @return array + */ + public static function partialOperationProvider(): array + { + return [ + 'include' => ['include'], + 'exclude' => ['exclude'], + 'only' => ['only'], + 'except' => ['except'], + ]; + } + + #[WithConfig('data.ignore_invalid_partials', true)] + public function testWillIgnoreANestedFieldThatDoesNotExistWhenConfigured(): void + { + // Upstream's second half; the option is read once at boot, so it is set for the whole test. + $this->assertSame([ + 'string' => 'World', + ], (new MissingNestedFieldData)->include('certainly-not-simple.string', 'string')->toArray()); + } + + /** + * @param Closure(): TransformationContextFactory $factory + */ + #[DataProvider('exceptsProvider')] + public function testCanExecuteExcepts(Closure $factory, array $expectedTransformed): void + { + $this->assertSame($expectedTransformed, VisibleFieldsData::instance()->transform($factory())); + } + + /** + * Provide except selections and their transformed output. + */ + public static function exceptsProvider(): iterable + { + yield 'single field' => [ + fn (): TransformationContextFactory => TransformationContextFactory::create() + ->except('single'), + [ + 'string' => 'hello', + 'int' => 42, + 'nested' => [ + 'a' => ['string' => 'hello', 'int' => 42], + 'b' => ['string' => 'hello', 'int' => 42], + ], + 'collection' => [ + ['string' => 'hello', 'int' => 42], + ['string' => 'hello', 'int' => 42], + ], + ], + ]; + + yield 'multiple fields' => [ + fn (): TransformationContextFactory => TransformationContextFactory::create() + ->except('{string,int,single}'), + [ + 'nested' => [ + 'a' => ['string' => 'hello', 'int' => 42], + 'b' => ['string' => 'hello', 'int' => 42], + ], + 'collection' => [ + ['string' => 'hello', 'int' => 42], + ['string' => 'hello', 'int' => 42], + ], + ], + ]; + + yield 'all' => [ + fn (): TransformationContextFactory => TransformationContextFactory::create() + ->except('*'), + [], + ]; + + yield 'nested data object single field' => [ + fn (): TransformationContextFactory => TransformationContextFactory::create() + ->except('string', 'int', 'single', 'collection') // ignore non nested object fields + ->except('nested.a'), + [ + 'nested' => [ + 'b' => ['string' => 'hello', 'int' => 42], + ], + ], + ]; + + yield 'nested data object multiple fields' => [ + fn (): TransformationContextFactory => TransformationContextFactory::create() + ->except('string', 'int', 'single', 'collection') // ignore non nested object fields + ->except('nested.{a,b}'), + [ + 'nested' => [], + ], + ]; + + yield 'nested data object all' => [ + fn (): TransformationContextFactory => TransformationContextFactory::create() + ->except('string', 'int', 'single', 'collection') // ignore non nested object fields + ->except('nested.*'), + [ + 'nested' => [], + ], + ]; + + yield 'nested data collectable single field' => [ + fn (): TransformationContextFactory => TransformationContextFactory::create() + ->except('string', 'int', 'single', 'nested') // ignore non collection fields + ->except('collection.string'), + [ + 'collection' => [ + ['int' => 42], + ['int' => 42], + ], + ], + ]; + + yield 'nested data collectable multiple fields' => [ + fn (): TransformationContextFactory => TransformationContextFactory::create() + ->except('string', 'int', 'single', 'nested') // ignore non collection fields + ->except('collection.{string,int}'), + [ + 'collection' => [ + [], + [], + ], + ], + ]; + + yield 'nested data collectable all' => [ + fn (): TransformationContextFactory => TransformationContextFactory::create() + ->except('string', 'int', 'single', 'nested') // ignore non collection fields + ->except('collection.*'), + [ + 'collection' => [ + [], + [], + ], + ], + ]; + + yield 'combination' => [ + fn (): TransformationContextFactory => TransformationContextFactory::create() + ->except('string', 'int', 'single.string') + ->except('collection.string') + ->except('nested.a.string'), + [ + 'single' => ['int' => 42], + 'nested' => [ + 'a' => ['int' => 42], + 'b' => ['string' => 'hello', 'int' => 42], + ], + 'collection' => [ + ['int' => 42], + ['int' => 42], + ], + ], + ]; + } + + /** + * @param Closure(): TransformationContextFactory $factory + */ + #[DataProvider('onlysProvider')] + public function testCanExecuteOnlys(Closure $factory, array $expectedTransformed): void + { + $this->assertSame($expectedTransformed, VisibleFieldsData::instance()->transform($factory())); + } + + /** + * Provide only selections and their transformed output. + */ + public static function onlysProvider(): iterable + { + yield 'single field' => [ + fn (): TransformationContextFactory => TransformationContextFactory::create() + ->only('single'), + [ + 'single' => ['string' => 'hello', 'int' => 42], + ], + ]; + + yield 'multiple fields' => [ + fn (): TransformationContextFactory => TransformationContextFactory::create() + ->only('{string,int,single}'), + [ + 'string' => 'hello', + 'int' => 42, + 'single' => ['string' => 'hello', 'int' => 42], + ], + ]; + + yield 'all' => [ + fn (): TransformationContextFactory => TransformationContextFactory::create() + ->only('*'), + [ + 'string' => 'hello', + 'int' => 42, + 'single' => ['string' => 'hello', 'int' => 42], + 'nested' => [ + 'a' => ['string' => 'hello', 'int' => 42], + 'b' => ['string' => 'hello', 'int' => 42], + ], + 'collection' => [ + ['string' => 'hello', 'int' => 42], + ['string' => 'hello', 'int' => 42], + ], + ], + ]; + + yield 'nested data object single field' => [ + fn (): TransformationContextFactory => TransformationContextFactory::create() + ->only('nested.a'), + [ + 'nested' => [ + 'a' => ['string' => 'hello', 'int' => 42], + ], + ], + ]; + + yield 'nested data object multiple fields' => [ + fn (): TransformationContextFactory => TransformationContextFactory::create() + ->only('nested.{a,b}'), + [ + 'nested' => [ + 'a' => ['string' => 'hello', 'int' => 42], + 'b' => ['string' => 'hello', 'int' => 42], + ], + ], + ]; + + yield 'nested data object all' => [ + fn (): TransformationContextFactory => TransformationContextFactory::create() + ->only('nested.*'), + [ + 'nested' => [ + 'a' => ['string' => 'hello', 'int' => 42], + 'b' => ['string' => 'hello', 'int' => 42], + ], + ], + ]; + + yield 'nested data collectable single field' => [ + fn (): TransformationContextFactory => TransformationContextFactory::create() + ->only('collection.string'), + [ + 'collection' => [ + ['string' => 'hello'], + ['string' => 'hello'], + ], + ], + ]; + + yield 'nested data collectable multiple fields' => [ + fn (): TransformationContextFactory => TransformationContextFactory::create() + ->only('collection.{string,int}'), + [ + 'collection' => [ + ['string' => 'hello', 'int' => 42], + ['string' => 'hello', 'int' => 42], + ], + ], + ]; + + yield 'nested data collectable all' => [ + fn (): TransformationContextFactory => TransformationContextFactory::create() + ->only('collection.*'), + [ + 'collection' => [ + ['string' => 'hello', 'int' => 42], + ['string' => 'hello', 'int' => 42], + ], + ], + ]; + + yield 'combination' => [ + fn (): TransformationContextFactory => TransformationContextFactory::create() + ->only('string', 'single.string') + ->only('collection.string') + ->only('nested.a.string'), + [ + 'string' => 'hello', + 'single' => ['string' => 'hello'], + 'nested' => [ + 'a' => ['string' => 'hello'], + ], + 'collection' => [ + ['string' => 'hello'], + ['string' => 'hello'], + ], + ], + ]; + } + + /** + * @param Closure(): TransformationContextFactory $factory + */ + #[DataProvider('includesProvider')] + public function testCanExecuteIncludes(Closure $factory, array $expectedTransformed): void + { + $this->assertSame($expectedTransformed, LazyVisibleFieldsData::instance(false)->transform($factory())); + } + + /** + * Provide include selections and their transformed output. + */ + public static function includesProvider(): iterable + { + yield 'single field' => [ + fn (): TransformationContextFactory => TransformationContextFactory::create() + ->include('single'), + [ + 'single' => [], + ], + ]; + + yield 'multiple fields' => [ + fn (): TransformationContextFactory => TransformationContextFactory::create() + ->include('{string,int,single}'), + [ + 'string' => 'hello', + 'int' => 42, + 'single' => [], + ], + ]; + + yield 'all' => [ + fn (): TransformationContextFactory => TransformationContextFactory::create() + ->include('*'), + [ + 'string' => 'hello', + 'int' => 42, + 'single' => ['string' => 'hello', 'int' => 42], + 'nested' => [ + 'a' => ['string' => 'hello', 'int' => 42], + 'b' => ['string' => 'hello', 'int' => 42], + ], + 'collection' => [ + ['string' => 'hello', 'int' => 42], + ['string' => 'hello', 'int' => 42], + ], + ], + ]; + + yield 'nested data object single field' => [ + fn (): TransformationContextFactory => TransformationContextFactory::create() + ->only('nested') // ignore non nested object fields + ->include('nested.a'), + [ + 'nested' => [ + 'a' => [], + ], + ], + ]; + + yield 'nested data object multiple fields' => [ + fn (): TransformationContextFactory => TransformationContextFactory::create() + ->only('nested') // ignore non nested object fields + ->include('nested.{a,b}'), + [ + 'nested' => [ + 'a' => [], + 'b' => [], + ], + ], + ]; + + yield 'nested data object all' => [ + fn (): TransformationContextFactory => TransformationContextFactory::create() + ->only('nested') // ignore non nested object fields + ->include('nested.*'), + [ + 'nested' => [ + 'a' => ['string' => 'hello', 'int' => 42], + 'b' => ['string' => 'hello', 'int' => 42], + ], + ], + ]; + + yield 'nested data object deep nesting' => [ + fn (): TransformationContextFactory => TransformationContextFactory::create() + ->only('nested') // ignore non nested object fields + ->include('nested.a.string', 'nested.b.int'), + [ + 'nested' => [ + 'a' => ['string' => 'hello'], + 'b' => ['int' => 42], + ], + ], + ]; + + yield 'nested data collectable single field' => [ + fn (): TransformationContextFactory => TransformationContextFactory::create() + ->only('collection') // ignore non collection fields + ->include('collection.string'), + [ + 'collection' => [ + ['string' => 'hello'], + ['string' => 'hello'], + ], + ], + ]; + + yield 'nested data collectable multiple fields' => [ + fn (): TransformationContextFactory => TransformationContextFactory::create() + ->only('collection') // ignore non collection fields + ->include('collection.{string,int}'), + [ + 'collection' => [ + ['string' => 'hello', 'int' => 42], + ['string' => 'hello', 'int' => 42], + ], + ], + ]; + + yield 'nested data collectable all' => [ + fn (): TransformationContextFactory => TransformationContextFactory::create() + ->only('collection') // ignore non collection fields + ->include('collection.*'), + [ + 'collection' => [ + ['string' => 'hello', 'int' => 42], + ['string' => 'hello', 'int' => 42], + ], + ], + ]; + + yield 'combination' => [ + fn (): TransformationContextFactory => TransformationContextFactory::create() + ->include('string', 'single.string') + ->include('collection.string') + ->include('nested.a.string'), + [ + 'string' => 'hello', + 'single' => ['string' => 'hello'], + 'nested' => [ + 'a' => ['string' => 'hello'], + ], + 'collection' => [ + ['string' => 'hello'], + ['string' => 'hello'], + ], + ], + ]; + } + + /** + * @param Closure(): TransformationContextFactory $factory + */ + #[DataProvider('excludesProvider')] + public function testCanExecuteExcludes(Closure $factory, array $expectedTransformed): void + { + $this->assertSame($expectedTransformed, LazyVisibleFieldsData::instance(true)->transform($factory())); + } + + /** + * Provide exclude selections and their transformed output. + */ + public static function excludesProvider(): iterable + { + yield 'single field' => [ + fn (): TransformationContextFactory => TransformationContextFactory::create() + ->exclude('single'), + [ + 'string' => 'hello', + 'int' => 42, + 'nested' => [ + 'a' => ['string' => 'hello', 'int' => 42], + 'b' => ['string' => 'hello', 'int' => 42], + ], + 'collection' => [ + ['string' => 'hello', 'int' => 42], + ['string' => 'hello', 'int' => 42], + ], + ], + ]; + + yield 'multiple fields' => [ + fn (): TransformationContextFactory => TransformationContextFactory::create() + ->exclude('{string,int,single}'), + [ + 'nested' => [ + 'a' => ['string' => 'hello', 'int' => 42], + 'b' => ['string' => 'hello', 'int' => 42], + ], + 'collection' => [ + ['string' => 'hello', 'int' => 42], + ['string' => 'hello', 'int' => 42], + ], + ], + ]; + + yield 'all' => [ + fn (): TransformationContextFactory => TransformationContextFactory::create() + ->exclude('*'), + [], + ]; + + yield 'nested data object single field' => [ + fn (): TransformationContextFactory => TransformationContextFactory::create() + ->only('nested') // ignore non nested object fields + ->exclude('nested.a'), + [ + 'nested' => [ + 'b' => ['string' => 'hello', 'int' => 42], + ], + ], + ]; + + yield 'nested data object multiple fields' => [ + fn (): TransformationContextFactory => TransformationContextFactory::create() + ->only('nested') // ignore non nested object fields + ->exclude('nested.{a,b}'), + [ + 'nested' => [], + ], + ]; + + yield 'nested data object all' => [ + fn (): TransformationContextFactory => TransformationContextFactory::create() + ->only('nested') // ignore non nested object fields + ->exclude('nested.*'), + [ + 'nested' => [], + ], + ]; + + yield 'nested data object deep nesting' => [ + fn (): TransformationContextFactory => TransformationContextFactory::create() + ->only('nested') // ignore non nested object fields + ->exclude('nested.a.string', 'nested.b.int'), + [ + 'nested' => [ + 'a' => ['int' => 42], + 'b' => ['string' => 'hello'], + ], + ], + ]; + + yield 'nested data collectable single field' => [ + fn (): TransformationContextFactory => TransformationContextFactory::create() + ->only('collection') // ignore non collection fields + ->exclude('collection.string'), + [ + 'collection' => [ + ['int' => 42], + ['int' => 42], + ], + ], + ]; + + yield 'nested data collectable multiple fields' => [ + fn (): TransformationContextFactory => TransformationContextFactory::create() + ->only('collection') // ignore non collection fields + ->exclude('collection.{string,int}'), + [ + 'collection' => [ + [], + [], + ], + ], + ]; + + yield 'nested data collectable all' => [ + fn (): TransformationContextFactory => TransformationContextFactory::create() + ->only('collection') // ignore non collection fields + ->exclude('collection.*'), + [ + 'collection' => [ + [], + [], + ], + ], + ]; + + yield 'combination' => [ + fn (): TransformationContextFactory => TransformationContextFactory::create() + ->exclude('string', 'single.string') + ->exclude('collection.string') + ->exclude('nested.a.string'), + [ + 'int' => 42, + 'single' => ['int' => 42], + 'nested' => [ + 'a' => ['int' => 42], + 'b' => ['string' => 'hello', 'int' => 42], + ], + 'collection' => [ + ['int' => 42], + ['int' => 42], + ], + ], + ]; + } + + public function testCanCombineAllThePartials(): void + { + $data = new LazyVisibleFieldsData( + Lazy::create(fn (): string => 'hello'), + Lazy::create(fn (): int => 42), + Lazy::create(fn (): LazyVisibleFieldsSingleData => LazyVisibleFieldsSingleData::instance(true))->defaultIncluded(), + Lazy::create(fn (): LazyVisibleFieldsNestedData => LazyVisibleFieldsNestedData::instance(false)), + Lazy::create(fn (): array => [ + LazyVisibleFieldsSingleData::instance(false), + LazyVisibleFieldsSingleData::instance(true), + ]), + ); + + $factory = TransformationContextFactory::create() + ->except('int', 'collection.int', 'nested.b.int') + ->only('single.*', 'nested.*', 'collection.*', 'string') + ->include('nested.a.string', 'nested.b.*', 'collection.string') + ->exclude('single.int'); + + $this->assertSame([ + 'single' => ['string' => 'hello'], + 'nested' => [ + 'a' => ['string' => 'hello'], + 'b' => ['string' => 'hello'], + ], + 'collection' => [ + ['string' => 'hello'], + ['string' => 'hello'], + ], + ], $data->transform($factory)); + } + + // REMOVED: 'can handle custom transformation contexts'; TransformationContext is a final immutable value and the + // transformation engine is fixed, so there are no replaceable resolvers to receive a custom context subclass. +} + +class MissingNestedFieldData extends Data +{ + public Lazy|SimpleData $simple; + + public Lazy|string $string; + + /** + * Create a data object with lazy nested and string values. + */ + public function __construct() + { + $this->simple = Lazy::create(fn (): SimpleData => new SimpleData('Hello')); + $this->string = Lazy::create(fn (): string => 'World'); + } +} + +class VisibleFieldsSingleData extends Data +{ + /** + * Create a data object with a string and an integer. + */ + public function __construct( + public string $string, + public int $int + ) { + } + + /** + * Create the standard instance. + */ + public static function instance(): self + { + return new self('hello', 42); + } +} + +class VisibleFieldsNestedData extends Data +{ + /** + * Create a data object with two nested objects. + */ + public function __construct( + public VisibleFieldsSingleData $a, + public VisibleFieldsSingleData $b, + ) { + } + + /** + * Create the standard instance. + */ + public static function instance(): self + { + return new self( + VisibleFieldsSingleData::instance(), + VisibleFieldsSingleData::instance(), + ); + } +} + +class VisibleFieldsData extends Data +{ + /** + * Create a data object with scalar, nested and collection values. + */ + public function __construct( + public string $string, + public int $int, + public VisibleFieldsSingleData $single, + public VisibleFieldsNestedData $nested, + #[DataCollectionOf(VisibleFieldsSingleData::class)] + public array $collection, + ) { + } + + /** + * Create the standard instance. + */ + public static function instance(): self + { + return new self( + 'hello', + 42, + VisibleFieldsSingleData::instance(), + VisibleFieldsNestedData::instance(), + [ + VisibleFieldsSingleData::instance(), + VisibleFieldsSingleData::instance(), + ], + ); + } +} + +class LazyVisibleFieldsSingleData extends Data +{ + /** + * Create a data object with lazy string and integer values. + */ + public function __construct( + public Lazy|string $string, + public Lazy|int $int + ) { + } + + /** + * Create the standard instance, with its lazy values optionally included by default. + */ + public static function instance(bool $includeByDefault): self + { + return new self( + Lazy::create(fn (): string => 'hello')->defaultIncluded($includeByDefault), + Lazy::create(fn (): int => 42)->defaultIncluded($includeByDefault) + ); + } +} + +class LazyVisibleFieldsNestedData extends Data +{ + /** + * Create a data object with two lazy nested objects. + */ + public function __construct( + public Lazy|LazyVisibleFieldsSingleData $a, + public Lazy|LazyVisibleFieldsSingleData $b, + ) { + } + + /** + * Create the standard instance, with its lazy values optionally included by default. + */ + public static function instance(bool $includeByDefault): self + { + return new self( + Lazy::create(fn (): LazyVisibleFieldsSingleData => LazyVisibleFieldsSingleData::instance($includeByDefault))->defaultIncluded($includeByDefault), + Lazy::create(fn (): LazyVisibleFieldsSingleData => LazyVisibleFieldsSingleData::instance($includeByDefault))->defaultIncluded($includeByDefault), + ); + } +} + +class LazyVisibleFieldsData extends Data +{ + /** + * Create a data object with lazy scalar, nested and collection values. + */ + public function __construct( + public Lazy|string $string, + public Lazy|int $int, + public Lazy|LazyVisibleFieldsSingleData $single, + public Lazy|LazyVisibleFieldsNestedData $nested, + #[DataCollectionOf(LazyVisibleFieldsSingleData::class)] + public Lazy|array $collection, + ) { + } + + /** + * Create the standard instance, with its lazy values optionally included by default. + */ + public static function instance(bool $includeByDefault): self + { + return new self( + Lazy::create(fn (): string => 'hello')->defaultIncluded($includeByDefault), + Lazy::create(fn (): int => 42)->defaultIncluded($includeByDefault), + Lazy::create(fn (): LazyVisibleFieldsSingleData => LazyVisibleFieldsSingleData::instance($includeByDefault))->defaultIncluded($includeByDefault), + Lazy::create(fn (): LazyVisibleFieldsNestedData => LazyVisibleFieldsNestedData::instance($includeByDefault))->defaultIncluded($includeByDefault), + Lazy::create(fn (): array => [ + LazyVisibleFieldsSingleData::instance($includeByDefault), + LazyVisibleFieldsSingleData::instance($includeByDefault), + ])->defaultIncluded($includeByDefault), + ); + } +} diff --git a/tests/Data/RuleInferrers/RequiredRuleInferrerTest.php b/tests/Data/RuleInferrers/RequiredRuleInferrerTest.php new file mode 100644 index 0000000000..794becfafc --- /dev/null +++ b/tests/Data/RuleInferrers/RequiredRuleInferrerTest.php @@ -0,0 +1,131 @@ +assertSame(['required', 'string'], $dataClass::getValidationRules([])['string']); + } + + public function testWontAddARequiredRuleWhenAPropertyIsNullable(): void + { + $dataClass = new class extends Data { + public ?string $string; + }; + + $this->assertSame(['nullable', 'string'], $dataClass::getValidationRules([])['string']); + } + + public function testWontAddARequiredRuleWhenAPropertyAlreadyContainsARequiredRule(): void + { + $dataClass = new class extends Data { + #[RequiredIf('bla')] + public string $string; + }; + + $this->assertSame(['string', 'required_if:bla'], $dataClass::getValidationRules([])['string']); + } + + public function testWontAddARequiredRuleWhenAPropertyAlreadyContainsARequiredObjectRule(): void + { + $dataClass = new class extends Data { + #[Required] + public string $string; + }; + + $this->assertSame(['string', 'required'], $dataClass::getValidationRules([])['string']); + } + + public function testWillAddARequiredRuleWhenAPropertyContainsABooleanRule(): void + { + $dataClass = new class extends Data { + #[BooleanType] + public string $string; + }; + + $this->assertSame(['required', 'string', 'boolean'], $dataClass::getValidationRules([])['string']); + } + + // Upstream's inferrer skips required when a nullable rule precedes it, which only a custom inferrer + // ordered before it can add. Declared attributes follow inference here, as in upstream's default + // inferrer order, so a declared nullable rule keeps the required rule. + public function testKeepsTheRequiredRuleWhenANullableRuleIsDeclared(): void + { + $dataClass = new class extends Data { + #[Nullable] + public string $string; + }; + + $this->assertSame(['required', 'string', 'nullable'], $dataClass::getValidationRules([])['string']); + } + + public function testHasSupportForRulesThatCannotBeConvertedToString(): void + { + $dataClass = new class extends Data { + #[Enum(new BaseEnum(DummyBackedEnum::class))] + public string $string; + }; + + $this->assertEquals( + ['required', 'string', new BaseEnum(DummyBackedEnum::class)], + $dataClass::getValidationRules([])['string'], + ); + } + + public function testWontAddRequiredToADataCollectionSinceItIsAlreadyPresent(): void + { + $dataClass = new class extends Data { + #[DataCollectionOf(SimpleData::class)] + public DataCollection $collection; + }; + + $this->assertSame(['present', 'array'], $dataClass::getValidationRules([])['collection']); + } + + // Upstream passes an array where the inferrer expects PropertyRules and only asserts the resulting TypeError. + public function testWontAddRequiredRulesToUndefinableProperties(): void + { + $dataClass = new class extends Data { + public string|Optional $string; + }; + + $this->assertSame(['sometimes', 'string'], $dataClass::getValidationRules([])['string']); + } +} diff --git a/tests/Data/SerializeableTest.php b/tests/Data/SerializeableTest.php new file mode 100644 index 0000000000..8d34a89b75 --- /dev/null +++ b/tests/Data/SerializeableTest.php @@ -0,0 +1,177 @@ +assertSame( + 'O:39:"Hypervel\Tests\Data\Fixtures\SimpleData":4:{s:14:"~*~_additional";a:0:{}s:21:"~*~partialDefinitions";N;s:7:"~*~wrap";N;s:6:"string";s:11:"Hello world";}', + $this->readable($serialized), + ); + + $unserialized = unserialize($serialized); + + $this->assertInstanceOf(SimpleData::class, $unserialized); + $this->assertSame('Hello world', $unserialized->string); + } + + public function testCanSerializeAndUnserializeADataObjectWithAdditionalData(): void + { + $object = SimpleData::from('Hello world')->additional([ + 'int' => 69, + ]); + + $serialized = serialize($object); + + $this->assertSame( + 'O:39:"Hypervel\Tests\Data\Fixtures\SimpleData":4:{s:14:"~*~_additional";a:1:{s:3:"int";i:69;}s:21:"~*~partialDefinitions";N;s:7:"~*~wrap";N;s:6:"string";s:11:"Hello world";}', + $this->readable($serialized), + ); + + $unserialized = unserialize($serialized); + + $this->assertInstanceOf(SimpleData::class, $unserialized); + $this->assertSame('Hello world', $unserialized->string); + $this->assertSame(['int' => 69], $unserialized->getAdditionalData()); + } + + public function testCanSerializeAndUnserializeADataObjectWithVirtualProperties(): void + { + $object = new SimpleDataWithPropertyHooks; + + $serialized = serialize($object); + + $this->assertSame( + 'O:56:"Hypervel\Tests\Data\Fixtures\SimpleDataWithPropertyHooks":5:{s:14:"~*~_additional";a:0:{}s:21:"~*~partialDefinitions";N;s:7:"~*~wrap";N;s:6:"backed";s:7:"default";s:22:"~*~constructorProperty";s:7:"default";}', + $this->readable($serialized), + ); + + $unserialized = unserialize($serialized); + + $this->assertInstanceOf(SimpleDataWithPropertyHooks::class, $unserialized); + $this->assertSame($object->virtual, $unserialized->virtual); + } + + public function testCanSerializeAndUnserializeADataObjectWithBackedProperties(): void + { + $object = new SimpleDataWithPropertyHooks; + $object->backed = 'Hello world'; + + $serialized = serialize($object); + + $this->assertSame( + 'O:56:"Hypervel\Tests\Data\Fixtures\SimpleDataWithPropertyHooks":5:{s:14:"~*~_additional";a:0:{}s:21:"~*~partialDefinitions";N;s:7:"~*~wrap";N;s:6:"backed";s:11:"Hello world";s:22:"~*~constructorProperty";s:7:"default";}', + $this->readable($serialized), + ); + + $unserialized = unserialize($serialized); + + $this->assertInstanceOf(SimpleDataWithPropertyHooks::class, $unserialized); + $this->assertSame($object->backed, $unserialized->backed); + } + + public function testCanSerializeAndUnserializeADataCollection(): void + { + $collection = new DataCollection(SimpleData::class, ['A', 'B']); + + $serialized = serialize($collection); + + $this->assertSame( + 'O:28:"Hypervel\Data\DataCollection":4:{s:8:"~*~items";O:27:"Hypervel\Support\Collection":2:{s:8:"~*~items";a:2:{i:0;O:39:"Hypervel\Tests\Data\Fixtures\SimpleData":4:{s:14:"~*~_additional";a:0:{}s:21:"~*~partialDefinitions";N;s:7:"~*~wrap";N;s:6:"string";s:1:"A";}i:1;O:39:"Hypervel\Tests\Data\Fixtures\SimpleData":4:{s:14:"~*~_additional";a:0:{}s:21:"~*~partialDefinitions";N;s:7:"~*~wrap";N;s:6:"string";s:1:"B";}}s:28:"~*~escapeWhenCastingToString";b:0;}s:9:"dataClass";s:39:"Hypervel\Tests\Data\Fixtures\SimpleData";s:21:"~*~partialDefinitions";N;s:7:"~*~wrap";N;}', + $this->readable($serialized), + ); + + $unserialized = unserialize($serialized); + + $this->assertInstanceOf(DataCollection::class, $unserialized); + $this->assertEquals(new DataCollection(SimpleData::class, ['A', 'B']), $unserialized); + } + + public function testWillKeepContextAttachedToDataWhenSerialized(): void + { + $object = LazyData::from('Hello world')->include('name'); + + $unserialized = unserialize(serialize($object)); + + $this->assertInstanceOf(LazyData::class, $unserialized); + $this->assertSame(['name' => 'Hello world'], $unserialized->toArray()); + } + + public function testIsPossibleToAddPartialsWithClosuresAndSerializeThem(): void + { + $object = LazyData::from('Hello world')->includeWhen( + 'name', + fn (LazyData $data): bool => $data->name instanceof DefaultLazy + ); + + $unserialized = unserialize(serialize($object)); + + $this->assertInstanceOf(LazyData::class, $unserialized); + $this->assertSame(['name' => 'Hello world'], $unserialized->toArray()); + } + + public function testIsPossibleToSerializeConditionalLazyProperties(): void + { + $object = new LazyData(Lazy::when( + fn (): bool => true, + fn (): string => 'Hello world' + )); + + $unserialized = unserialize(serialize($object)); + + $this->assertInstanceOf(LazyData::class, $unserialized); + $this->assertSame(['name' => 'Hello world'], $unserialized->toArray()); + } + + public function testCanJsonEncodeExceptionTraceWithArgsWhenNotAllRequiredPropertiesPassed(): void + { + // With zend.exception_ignore_args off, the trace contains the arguments, and all of them must encode. + $ignoreArgs = ini_set('zend.exception_ignore_args', '0'); + + try { + SimpleData::from([]); + $this->fail('Expected the missing property to fail creation.'); + } catch (CannotCreateData $exception) { + $this->assertJson(json_encode($exception->getTrace(), JSON_THROW_ON_ERROR)); + } finally { + ini_set('zend.exception_ignore_args', (string) $ignoreArgs); + } + } + + /** + * Show the NUL bytes around protected property names as ~. + */ + protected function readable(string $serialized): string + { + return str_replace("\0", '~', $serialized); + } +} diff --git a/tests/Data/Support/Annotations/DataIterableAnnotationReaderTest.php b/tests/Data/Support/Annotations/DataIterableAnnotationReaderTest.php new file mode 100644 index 0000000000..e301f28225 --- /dev/null +++ b/tests/Data/Support/Annotations/DataIterableAnnotationReaderTest.php @@ -0,0 +1,1005 @@ +assertSame($expected, $this->propertyItem(CollectionDataAnnotationsData::class, $property)); + } + + /** + * Get the data annotation properties with the item each resolves to. + * + * @return iterable + */ + public static function dataPropertyAnnotations(): iterable + { + yield 'propertyA' => ['propertyA', [SimpleData::class, true]]; + yield 'propertyB' => ['propertyB', [SimpleData::class, true]]; + yield 'propertyC' => ['propertyC', [SimpleData::class, true]]; + yield 'propertyD' => ['propertyD', [SimpleData::class, true]]; + yield 'propertyE' => ['propertyE', [SimpleData::class, true]]; + yield 'propertyF' => ['propertyF', [SimpleData::class, true]]; + yield 'propertyG' => ['propertyG', [SimpleData::class, true]]; + yield 'propertyH' => ['propertyH', null]; // Attribute + yield 'propertyI' => ['propertyI', null]; // Invalid definition + yield 'propertyJ' => ['propertyJ', null]; // No definition + yield 'propertyK' => ['propertyK', [SimpleData::class, true]]; + yield 'propertyL' => ['propertyL', [SimpleData::class, true]]; + yield 'propertyM' => ['propertyM', [SimpleData::class, true]]; + yield 'propertyU' => ['propertyU', [SimpleData::class, true]]; + yield 'propertyV' => ['propertyV', [SimpleDataWithUnicodeCharséÄöü::class, true]]; + } + + public function testCanGetTheDataClassForADataCollectionByClassAnnotation(): void + { + $annotations = (new DataIterableAnnotationReader)->getForClass(new ReflectionClass(CollectionDataAnnotationsData::class)); + + $this->assertSame([ + 'propertyN' => [SimpleData::class, true], + 'propertyO' => [SimpleData::class, true], + 'propertyP' => [SimpleData::class, true], + 'propertyQ' => [SimpleData::class, true], + 'propertyR' => [SimpleData::class, true], + 'propertyS' => [SimpleData::class, true], + 'propertyT' => [SimpleData::class, true], + 'propertyW' => [SimpleDataWithUnicodeCharséÄöü::class, true], + ], $this->resolvedItems($annotations, CollectionDataAnnotationsData::class)); + } + + public function testCanGetDataClassForADataCollectionByMethodAnnotation(): void + { + $annotations = (new DataIterableAnnotationReader)->getForMethod(new ReflectionMethod(CollectionDataAnnotationsData::class, 'method')); + + $this->assertSame([ + 'paramA' => [SimpleData::class, true], + 'paramB' => [SimpleData::class, true], + 'paramC' => [SimpleData::class, true], + 'paramD' => [SimpleData::class, true], + 'paramE' => [SimpleData::class, true], + 'paramF' => [SimpleData::class, true], + 'paramG' => [SimpleData::class, true], + 'paramH' => [SimpleData::class, true], + 'paramJ' => [SimpleData::class, true], + 'paramI' => [SimpleData::class, true], + 'paramK' => [SimpleData::class, true], + 'paramL' => [SimpleDataWithUnicodeCharséÄöü::class, true], + 'paramM' => [SimpleDataWithUnicodeCharséÄöü::class, true], + 'paramN' => [SimpleDataWithUnicodeCharséÄöü::class, true], + 'paramO' => [SimpleDataWithUnicodeCharséÄöü::class, true], + ], $this->resolvedItems($annotations, CollectionDataAnnotationsData::class)); + } + + /** + * @param null|array{string, bool} $expected + */ + #[DataProvider('iterablePropertyAnnotations')] + public function testCanGetTheIterableClassForACollectionByAnnotation(string $property, ?array $expected): void + { + $this->assertSame($expected, $this->propertyItem(CollectionNonDataAnnotationsData::class, $property)); + } + + /** + * Get the non-data annotation properties with the item each resolves to. + * + * @return iterable + */ + public static function iterablePropertyAnnotations(): iterable + { + yield 'propertyA' => ['propertyA', [DummyBackedEnum::class, false]]; + yield 'propertyB' => ['propertyB', [DummyBackedEnum::class, false]]; + yield 'propertyC' => ['propertyC', [DummyBackedEnum::class, false]]; + yield 'propertyD' => ['propertyD', [DummyBackedEnum::class, false]]; + yield 'propertyE' => ['propertyE', ['string', false]]; + yield 'propertyF' => ['propertyF', ['string', false]]; + yield 'propertyG' => ['propertyG', [DummyBackedEnum::class, false]]; + yield 'propertyH' => ['propertyH', null]; // Invalid + yield 'propertyI' => ['propertyI', null]; // No definition + yield 'propertyJ' => ['propertyJ', [DummyBackedEnum::class, false]]; + yield 'propertyK' => ['propertyK', [DummyBackedEnum::class, false]]; + yield 'propertyL' => ['propertyL', [DummyBackedEnum::class, false]]; + yield 'propertyP' => ['propertyP', [Error::class, true]]; + yield 'propertyR' => ['propertyR', [Error::class, true]]; + } + + public function testCanGetTheIterableClassForACollectionByClassAnnotation(): void + { + $annotations = (new DataIterableAnnotationReader)->getForClass(new ReflectionClass(CollectionNonDataAnnotationsData::class)); + + $this->assertSame([ + 'propertyM' => [DummyBackedEnum::class, false], + 'propertyN' => [DummyBackedEnum::class, false], + 'propertyO' => [DummyBackedEnum::class, false], + 'propertyQ' => [DummyBackedEnum::class, false], + ], $this->resolvedItems($annotations, CollectionNonDataAnnotationsData::class)); + } + + public function testCanGetIterableClassForADataByMethodAnnotation(): void + { + $annotations = (new DataIterableAnnotationReader)->getForMethod(new ReflectionMethod(CollectionNonDataAnnotationsData::class, 'method')); + + $this->assertSame([ + 'paramA' => [DummyBackedEnum::class, false], + 'paramB' => [DummyBackedEnum::class, false], + 'paramC' => [DummyBackedEnum::class, false], + 'paramD' => [DummyBackedEnum::class, false], + 'paramE' => [DummyBackedEnum::class, false], + 'paramF' => [DummyBackedEnum::class, false], + 'paramG' => [DummyBackedEnum::class, false], + 'paramH' => [DummyBackedEnum::class, false], + 'paramJ' => [DummyBackedEnum::class, false], + 'paramI' => [DummyBackedEnum::class, false], + 'paramK' => [DummyBackedEnum::class, false], + ], $this->resolvedItems($annotations, CollectionNonDataAnnotationsData::class)); + } + + public function testWillAlwaysPreferTheDataVersionOfTheAnnotationInUnions(): void + { + $dataClass = new class extends Data { + /** @var array */ + public array $property; + }; + $property = new ReflectionProperty($dataClass::class, 'property'); + + // The type factory picks the data item from a union annotation, which Spatie's reader does itself. + $type = (new DataTypeFactory(new PhpDocTypeNameResolver, new DataIterableAnnotationReader))->buildProperty( + $property->getType(), + $dataClass::class, + $property, + iterableAnnotations: (new DataIterableAnnotationReader)->getForProperty($property), + ); + + $this->assertSame(SimpleData::class, $type->getNamedTypes()[0]->dataClass); + } + + #[DataProvider('defaultPhpTypes')] + public function testWillRecognizeDefaultPhpTypes(string $type): void + { + $dataClass = new class extends Data { + /** @var array */ + public array $string; + + /** @var array */ + public array $int; + + /** @var array */ + public array $float; + + /** @var array */ + public array $bool; + + /** @var array */ + public array $mixed; + + /** @var array */ + public array $array; + + /** @var array */ + public array $iterable; + + /** @var array */ + public array $object; + + /** @var array */ + public array $callable; + }; + + $this->assertSame([$type, false], $this->propertyItem($dataClass::class, $type)); + } + + /** + * Get the PHP types an iterable annotation may name. + * + * @return array + */ + public static function defaultPhpTypes(): array + { + return [ + 'string' => ['string'], + 'int' => ['int'], + 'float' => ['float'], + 'bool' => ['bool'], + 'mixed' => ['mixed'], + 'array' => ['array'], + 'iterable' => ['iterable'], + 'object' => ['object'], + 'callable' => ['callable'], + ]; + } + + // REMOVED: 'can recognize the key of an iterable' and the int key types of the method cases; key types only fed + // Spatie's TypeScript transformer, which is not included, so the reader keeps none. + + /** + * Test array, generic, list, nullable, and union property annotations. + */ + public function testPropertyAnnotationsAreParsedStructurally(): void + { + $reader = new DataIterableAnnotationReader; + + $array = $reader->getForProperty(new ReflectionProperty(DataIterablePropertyFixture::class, 'array')); + $generic = $reader->getForProperty(new ReflectionProperty(DataIterablePropertyFixture::class, 'generic')); + $list = $reader->getForProperty(new ReflectionProperty(DataIterablePropertyFixture::class, 'list')); + $nullable = $reader->getForProperty(new ReflectionProperty(DataIterablePropertyFixture::class, 'nullable')); + $union = $reader->getForProperty(new ReflectionProperty(DataIterablePropertyFixture::class, 'union')); + + $this->assertAnnotation($array[0], 'array', 'FooData'); + $this->assertAnnotation($generic[0], 'array', 'FooData'); + $this->assertAnnotation($list[0], 'array', 'FooData'); + $this->assertAnnotation($nullable[0], 'Collection', 'FooData'); + $this->assertAnnotation($union[0], 'array', 'FooData'); + $this->assertAnnotation($union[1], 'Collection', 'BarData'); + $this->assertSame( + [DataIterablePropertyFixture::class], + array_values(array_unique(array_map( + fn (DataIterableAnnotation $annotation): string => $annotation->declaringClass, + [...$array, ...$generic, ...$list, ...$nullable, ...$union], + ))), + ); + $this->assertSame([], $reader->getForProperty( + new ReflectionProperty(DataIterablePropertyFixture::class, 'scalar'), + )); + } + + /** + * Test native types screen only declarations that cannot use iterable metadata. + */ + public function testNativeTypesScreenIterableAnnotations(): void + { + $factory = $this->factory(new DataIterableAnnotationReader); + $method = new ReflectionMethod($factory, 'typeCanUseIterableAnnotation'); + $class = new ReflectionClass(DataIterableNativeTypeFixture::class); + $expected = [ + 'scalar' => false, + 'nullableScalar' => false, + 'enum' => false, + 'date' => false, + 'data' => false, + 'optional' => false, + 'lazy' => false, + 'array' => true, + 'iterable' => true, + 'mixed' => true, + 'object' => true, + 'custom' => true, + 'union' => true, + 'intersection' => true, + ]; + + foreach ($expected as $property => $canUseAnnotation) { + $this->assertSame( + $canUseAnnotation, + $method->invoke($factory, $class->getProperty($property)->getType()), + $property, + ); + } + + $this->assertTrue($method->invoke( + $factory, + $class->getMethod('acceptsCallable')->getParameters()[0]->getType(), + )); + } + + /** + * Test parser construction is lazy and preserves annotation precedence. + */ + public function testParserIsBuiltOnceForEligibleMetadata(): void + { + $reader = new DataIterableAnnotationReader; + $factory = $this->factory($reader); + + $factory->build(new ReflectionClass(DataIterableScalarOnlyData::class)); + + $this->assertNull($this->readerProperty($reader, 'lexer')); + $this->assertNull($this->readerProperty($reader, 'parser')); + + $class = $factory->build(new ReflectionClass(ChildAnnotations::class)); + $lexer = $this->readerProperty($reader, 'lexer'); + $parser = $this->readerProperty($reader, 'parser'); + + $this->assertInstanceOf(Lexer::class, $lexer); + $this->assertInstanceOf(PhpDocParser::class, $parser); + $this->assertSame(ParentClassItem::class, $this->iterableItemClass($class->properties['parentOnly'])); + $this->assertSame(ChildClassItem::class, $this->iterableItemClass($class->properties['classItems'])); + $this->assertSame(InlineItem::class, $this->iterableItemClass($class->properties['inlineItems'])); + $this->assertSame(ConstructorItem::class, $this->iterableItemClass($class->properties['constructorItems'])); + + $reader->getForProperty(new ReflectionProperty(DataIterablePropertyFixture::class, 'array')); + + $this->assertSame($lexer, $this->readerProperty($reader, 'lexer')); + $this->assertSame($parser, $this->readerProperty($reader, 'parser')); + } + + // REMOVED: 'can caches the result'; metadata is built once per worker, so the reader keeps no per-class cache. + + /** + * @param class-string $className + */ + #[DataProvider('collectionClasses')] + public function testVerifiesTheCorrectCollectionAnnotationIsReturnedForAGivenClass(string $className, ?string $itemType): void + { + $annotation = (new DataIterableAnnotationReader)->getForCollectionClass(new ReflectionClass($className)); + + if ($itemType === null) { + $this->assertNull($annotation); + + return; + } + + $this->assertNotNull($annotation); + $this->assertSame($itemType, (string) $annotation->itemType); + $this->assertSame($className, $annotation->declaringClass); + } + + /** + * Get collection classes with the item type each declares. + * + * Upstream also asserts a data flag and key type; this reader returns only the item type, and the type factory decides whether it is data. + */ + public static function collectionClasses(): iterable + { + $simpleData = '\\' . SimpleData::class; + $enum = '\\' . DummyBackedEnum::class; + + yield 'DataCollectionWithTemplate' => [DataCollectionWithTemplate::class, $simpleData]; + yield 'DataCollectionWithoutTemplate' => [DataCollectionWithoutTemplate::class, $simpleData]; + yield 'DataCollectionWithCombinationType' => [DataCollectionWithCombinationType::class, "({$enum} | {$simpleData})"]; + yield 'DataCollectionWithIntegerKey' => [DataCollectionWithIntegerKey::class, $simpleData]; + yield 'DataCollectionWithCombinationKey' => [DataCollectionWithCombinationKey::class, $simpleData]; + yield 'DataCollectionWithoutKey' => [DataCollectionWithoutKey::class, $simpleData]; + yield 'NonDataCollectionWithTemplate' => [NonDataCollectionWithTemplate::class, $enum]; + yield 'NonDataCollectionWithoutTemplate' => [NonDataCollectionWithoutTemplate::class, $enum]; + yield 'NonDataCollectionWithCombinationType' => [NonDataCollectionWithCombinationType::class, "({$enum} | {$simpleData})"]; + yield 'NonDataCollectionWithIntegerKey' => [NonDataCollectionWithIntegerKey::class, $enum]; + yield 'NonDataCollectionWithCombinationKey' => [NonDataCollectionWithCombinationKey::class, $enum]; + yield 'NonDataCollectionWithoutKey' => [NonDataCollectionWithoutKey::class, $enum]; + yield 'CollectionWhoImplementsIterator' => [CollectionWhoImplementsIterator::class, $enum]; + yield 'CollectionWhoImplementsIteratorAggregate' => [CollectionWhoImplementsIteratorAggregate::class, $enum]; + yield 'CollectionWhoImplementsNothing' => [CollectionWhoImplementsNothing::class, null]; + yield 'CollectionWithoutDocBlock' => [CollectionWithoutDocBlock::class, null]; + yield 'CollectionWithoutType' => [CollectionWithoutType::class, null]; + yield 'interface' => [Enumerable::class, null]; + yield 'unbounded template' => [LengthAwarePaginator::class, null]; + } + + /** + * Assert one parsed iterable annotation. + */ + protected function assertAnnotation( + DataIterableAnnotation $annotation, + string $container, + string $item, + ): void { + $this->assertSame($container, $annotation->containerType); + $this->assertSame($item, (string) $annotation->itemType); + } + + /** + * Resolve the one annotation the reader finds for a property, or null when it finds none. + * + * @param class-string $class + * @return null|array{string, bool} + */ + protected function propertyItem(string $class, string $property): ?array + { + $annotations = (new DataIterableAnnotationReader)->getForProperty(new ReflectionProperty($class, $property)); + + if ($annotations === []) { + return null; + } + + $this->assertCount(1, $annotations); + + return $this->resolvedItem($annotations[0]); + } + + /** + * Resolve keyed class or method annotations, checking each names its declaration and declaring class. + * + * @param array> $annotations + * @param class-string $declaringClass + * @return array + */ + protected function resolvedItems(array $annotations, string $declaringClass): array + { + $resolved = []; + + foreach ($annotations as $name => $declared) { + $this->assertCount(1, $declared); + $this->assertSame($name, $declared[0]->property); + $this->assertSame($declaringClass, $declared[0]->declaringClass); + $resolved[$name] = $this->resolvedItem($declared[0]); + } + + return $resolved; + } + + /** + * Resolve an annotation's item type name in its declaring class, and whether it names a data class. + * + * @return array{string, bool} + */ + protected function resolvedItem(DataIterableAnnotation $annotation): array + { + $name = (new PhpDocTypeNameResolver)->resolve( + (string) $annotation->itemType, + new ReflectionClass($annotation->declaringClass), + ); + + return [$name, is_a($name, BaseData::class, true)]; + } + + /** + * Create the metadata factory with the given annotation reader. + */ + protected function factory(DataIterableAnnotationReader $reader): DataClassFactory + { + $defaults = require __DIR__ . '/../../../../src/data/config/data.php'; + $config = new DataConfig(new Repository(['data' => $defaults])); + $nameMapperResolver = new NameMapperResolver(new Container); + $typeFactory = new DataTypeFactory(new PhpDocTypeNameResolver, $reader); + $parameterFactory = new DataParameterFactory($typeFactory); + + return new DataClassFactory( + new DataPropertyFactory($typeFactory, $config, $nameMapperResolver), + new DataMethodFactory($parameterFactory, $typeFactory), + $parameterFactory, + $reader, + $nameMapperResolver, + $config, + ); + } + + /** + * Get an internal parser dependency for verification. + */ + protected function readerProperty(DataIterableAnnotationReader $reader, string $name): ?object + { + return (new ReflectionProperty($reader, $name))->getValue($reader); + } + + /** + * Get the concrete iterable item class from property metadata. + */ + protected function iterableItemClass(DataProperty $property): string + { + return $property->type->getIterableTypes()[0]->iterableItemType?->getNamedTypes()[0]->name ?? ''; + } +} + +class DataIterablePropertyFixture +{ + /** @var FooData[] */ + public array $array; + + /** @var array */ + public array $generic; + + /** @var list */ + public array $list; + + /** @var null|Collection */ + public ?object $nullable; + + /** @var array|Collection */ + public array|object $union; + + /** @var FooData */ + public object $scalar; +} + +enum DataIterableScalarStatus: string +{ + case Ready = 'ready'; +} + +class DataIterableNestedData extends Data +{ + public function __construct(public int $id) + { + } +} + +class DataIterableCustomContainer +{ +} + +class DataIterableNativeTypeFixture +{ + public string $scalar; + + public ?int $nullableScalar; + + public DataIterableScalarStatus $enum; + + public DateTimeImmutable $date; + + public DataIterableNestedData $data; + + public Optional|int $optional; + + public Lazy|string $lazy; + + public array $array; + + public iterable $iterable; + + public mixed $mixed; + + public object $object; + + public DataIterableCustomContainer $custom; + + public array|string $union; + + public Countable&IteratorAggregate $intersection; + + public function acceptsCallable(callable $callback): void + { + } +} + +/** + * @property array $identifier + * @property array $name + */ +class DataIterableScalarOnlyData extends Data +{ + /** @var array */ + public string $name; + + /** + * @param array $identifier + * @param array $name + * @param array $optional + * @param array $lazy + * @param array $date + * @param array $status + * @param array $child + */ + public function __construct( + public int $identifier, + string $name, + public Optional|int $optional, + public Lazy|string $lazy, + public DateTimeImmutable $date, + public DataIterableScalarStatus $status, + public DataIterableNestedData $child, + ) { + $this->name = $name; + } +} + +/** + * @template TKey of array-key + * @template TData of \Hypervel\Tests\Data\Fixtures\SimpleData + * + * @extends \Hypervel\Support\Collection + */ +class DataCollectionWithTemplate extends Collection +{ +} + +/** + * @extends \Hypervel\Support\Collection + */ +class DataCollectionWithoutTemplate extends Collection +{ +} + +/** + * @extends \Hypervel\Support\Collection + */ +class DataCollectionWithCombinationType extends Collection +{ +} + +/** + * @extends \Hypervel\Support\Collection + */ +class DataCollectionWithIntegerKey extends Collection +{ +} + +/** + * @extends \Hypervel\Support\Collection + */ +class DataCollectionWithCombinationKey extends Collection +{ +} + +/** + * @extends \Hypervel\Support\Collection<\Hypervel\Tests\Data\Fixtures\SimpleData> + */ +class DataCollectionWithoutKey extends Collection +{ +} + +/** + * @template TKey of array-key + * @template TValue of \Hypervel\Tests\Data\Fixtures\Enums\DummyBackedEnum + * + * @extends \Hypervel\Support\Collection + */ +class NonDataCollectionWithTemplate extends Collection +{ +} + +/** + * @extends \Hypervel\Support\Collection + */ +class NonDataCollectionWithoutTemplate extends Collection +{ +} + +/** + * @extends \Hypervel\Support\Collection + */ +class NonDataCollectionWithCombinationType extends Collection +{ +} + +/** + * @extends \Hypervel\Support\Collection + */ +class NonDataCollectionWithIntegerKey extends Collection +{ +} + +/** + * @extends \Hypervel\Support\Collection + */ +class NonDataCollectionWithCombinationKey extends Collection +{ +} + +/** + * @extends \Hypervel\Support\Collection<\Hypervel\Tests\Data\Fixtures\Enums\DummyBackedEnum> + */ +class NonDataCollectionWithoutKey extends Collection +{ +} + +/** + * @extends \Hypervel\Support\Collection + */ +class CollectionWhoImplementsIterator implements Iterator +{ + /** + * Get the current item. + */ + public function current(): mixed + { + return null; + } + + /** + * Move to the next item. + */ + public function next(): void + { + } + + /** + * Get the current key. + */ + public function key(): mixed + { + return null; + } + + /** + * Determine whether the current position is valid. + */ + public function valid(): bool + { + return false; + } + + /** + * Move to the first item. + */ + public function rewind(): void + { + } +} + +/** + * @extends \Hypervel\Support\Collection + */ +class CollectionWhoImplementsIteratorAggregate implements IteratorAggregate +{ + /** + * Get an iterator for the items. + */ + public function getIterator(): Traversable + { + return new ArrayIterator([]); + } +} + +/** + * @extends \Hypervel\Support\Collection + */ +class CollectionWhoImplementsNothing +{ +} + +class CollectionWithoutDocBlock extends Collection +{ +} + +/** + * @extends \Hypervel\Support\Collection + */ +class CollectionWithoutType extends Collection +{ +} + +class Error extends Data +{ +} + +class SimpleDataWithUnicodeCharséÄöü extends Data +{ + /** + * Create a data object whose class name has non-ASCII characters. + */ + public function __construct( + public string $string + ) { + } +} + +/** + * @property DataCollection<\Hypervel\Tests\Data\Fixtures\SimpleData> $propertyN + * @property \Hypervel\Tests\Data\Fixtures\SimpleData[] $propertyO + * @property DataCollection $propertyP + * @property array<\Hypervel\Tests\Data\Fixtures\SimpleData> $propertyQ + * @property \Hypervel\Tests\Data\Fixtures\SimpleData[] $propertyR + * @property array $propertyS + * @property null|\Hypervel\Support\Collection<\Hypervel\Tests\Data\Fixtures\SimpleData> $propertyT + * @property null|\Hypervel\Support\Collection<\Hypervel\Tests\Data\Support\Annotations\DataIterableAnnotationReaderTest\SimpleDataWithUnicodeCharséÄöü> $propertyW + */ +class CollectionDataAnnotationsData +{ + /** @var \Hypervel\Tests\Data\Fixtures\SimpleData[] */ + public array $propertyA; + + /** @var null|\Hypervel\Tests\Data\Fixtures\SimpleData[] */ + public ?array $propertyB; + + /** @var null|\Hypervel\Tests\Data\Fixtures\SimpleData[] */ + public ?array $propertyC; + + /** @var ?\Hypervel\Tests\Data\Fixtures\SimpleData[] */ + public array $propertyD; + + /** @var \Hypervel\Data\DataCollection<\Hypervel\Tests\Data\Fixtures\SimpleData> */ + public DataCollection $propertyE; + + /** @var ?\Hypervel\Data\DataCollection<\Hypervel\Tests\Data\Fixtures\SimpleData> */ + public ?DataCollection $propertyF; + + /** @var SimpleData[] */ + public array $propertyG; + + #[DataCollectionOf(SimpleData::class)] + public DataCollection $propertyH; + + /** @var SimpleData */ + public DataCollection $propertyI; + + public DataCollection $propertyJ; + + /** @var array<\Hypervel\Tests\Data\Fixtures\SimpleData> */ + public array $propertyK; + + /** @var LengthAwarePaginator<\Hypervel\Tests\Data\Fixtures\SimpleData> */ + public LengthAwarePaginator $propertyL; + + /** @var \Hypervel\Support\Collection<\Hypervel\Tests\Data\Fixtures\SimpleData> */ + public Collection $propertyM; + + public DataCollection $propertyN; + + public DataCollection $propertyO; + + public DataCollection $propertyP; + + public array $propertyQ; + + public array $propertyR; + + public array $propertyS; + + public ?array $propertyT; + + /** @var null|\Hypervel\Support\Collection<\Hypervel\Tests\Data\Fixtures\SimpleData> */ + public ?array $propertyU; + + /** @var null|\Hypervel\Support\Collection<\Hypervel\Tests\Data\Support\Annotations\DataIterableAnnotationReaderTest\SimpleDataWithUnicodeCharséÄöü> */ + public ?array $propertyV; + + public ?array $propertyW; + + /** + * Accept parameters annotated with data items. + * + * @param null|\Hypervel\Tests\Data\Fixtures\SimpleData[] $paramA + * @param null|\Hypervel\Tests\Data\Fixtures\SimpleData[] $paramB + * @param ?\Hypervel\Tests\Data\Fixtures\SimpleData[] $paramC + * @param ?\Hypervel\Tests\Data\Fixtures\SimpleData[] $paramD + * @param \Hypervel\Data\DataCollection<\Hypervel\Tests\Data\Fixtures\SimpleData> $paramE + * @param ?\Hypervel\Data\DataCollection<\Hypervel\Tests\Data\Fixtures\SimpleData> $paramF + * @param SimpleData[] $paramG + * @param array $paramH + * @param array $paramJ + * @param array $paramI + * @param null|\Hypervel\Data\DataCollection<\Hypervel\Tests\Data\Fixtures\SimpleData> $paramK + * @param null|\Hypervel\Data\DataCollection<\Hypervel\Tests\Data\Support\Annotations\DataIterableAnnotationReaderTest\SimpleDataWithUnicodeCharséÄöü> $paramL + * @param null|Collection<\Hypervel\Tests\Data\Support\Annotations\DataIterableAnnotationReaderTest\SimpleDataWithUnicodeCharséÄöü> $paramM + * @param null|array<\Hypervel\Tests\Data\Support\Annotations\DataIterableAnnotationReaderTest\SimpleDataWithUnicodeCharséÄöü> $paramN + * @param null|\Hypervel\Tests\Data\Support\Annotations\DataIterableAnnotationReaderTest\SimpleDataWithUnicodeCharséÄöü[] $paramO + */ + public function method( + array $paramA, + ?array $paramB, + ?array $paramC, + array $paramD, + DataCollection $paramE, + ?DataCollection $paramF, + array $paramG, + array $paramH, + array $paramJ, + array $paramI, + ?array $paramK, + ?DataCollection $paramL, + ?Collection $paramM, + ?array $paramN, + ?array $paramO, + ): void { + } +} + +/** + * @property \Hypervel\Tests\Data\Fixtures\Enums\DummyBackedEnum[] $propertyM + * @property array<\Hypervel\Tests\Data\Fixtures\Enums\DummyBackedEnum> $propertyN + * @property array $propertyO + * @property null|array $propertyQ + */ +class CollectionNonDataAnnotationsData +{ + /** @var \Hypervel\Tests\Data\Fixtures\Enums\DummyBackedEnum[] */ + public array $propertyA; + + /** @var null|\Hypervel\Tests\Data\Fixtures\Enums\DummyBackedEnum[] */ + public ?array $propertyB; + + /** @var null|\Hypervel\Tests\Data\Fixtures\Enums\DummyBackedEnum[] */ + public ?array $propertyC; + + /** @var ?\Hypervel\Tests\Data\Fixtures\Enums\DummyBackedEnum[] */ + public array $propertyD; + + /** @var array */ + public array $propertyE; + + /** @var array */ + public array $propertyF; + + /** @var DummyBackedEnum[] */ + public array $propertyG; + + /** @var DummyBackedEnum */ + public array $propertyH; + + public array $propertyI; + + /** @var array<\Hypervel\Tests\Data\Fixtures\Enums\DummyBackedEnum> */ + public array $propertyJ; + + /** @var LengthAwarePaginator<\Hypervel\Tests\Data\Fixtures\Enums\DummyBackedEnum> */ + public LengthAwarePaginator $propertyK; + + /** @var \Hypervel\Support\Collection<\Hypervel\Tests\Data\Fixtures\Enums\DummyBackedEnum> */ + public Collection $propertyL; + + public array $propertyM; + + public array $propertyN; + + public array $propertyO; + + /** @var \Hypervel\Support\Collection */ + public Collection $propertyP; + + public array $propertyQ; + + /** @var null|\Hypervel\Support\Collection */ + public ?Collection $propertyR; + + /** + * Accept parameters annotated with non-data items. + * + * @param null|\Hypervel\Tests\Data\Fixtures\Enums\DummyBackedEnum[] $paramA + * @param null|\Hypervel\Tests\Data\Fixtures\Enums\DummyBackedEnum[] $paramB + * @param ?\Hypervel\Tests\Data\Fixtures\Enums\DummyBackedEnum[] $paramC + * @param ?\Hypervel\Tests\Data\Fixtures\Enums\DummyBackedEnum[] $paramD + * @param \Hypervel\Data\DataCollection<\Hypervel\Tests\Data\Fixtures\Enums\DummyBackedEnum> $paramE + * @param ?\Hypervel\Data\DataCollection<\Hypervel\Tests\Data\Fixtures\Enums\DummyBackedEnum> $paramF + * @param DummyBackedEnum[] $paramG + * @param array $paramH + * @param array $paramJ + * @param array $paramI + * @param null|\Hypervel\Data\DataCollection<\Hypervel\Tests\Data\Fixtures\Enums\DummyBackedEnum> $paramK + */ + public function method( + array $paramA, + ?array $paramB, + ?array $paramC, + array $paramD, + DataCollection $paramE, + ?DataCollection $paramF, + array $paramG, + array $paramH, + array $paramJ, + array $paramI, + ?array $paramK, + ): void { + } +} diff --git a/tests/Data/Support/Creation/ConstructionStateTest.php b/tests/Data/Support/Creation/ConstructionStateTest.php index 349c4b6bbb..a65211ccbc 100644 --- a/tests/Data/Support/Creation/ConstructionStateTest.php +++ b/tests/Data/Support/Creation/ConstructionStateTest.php @@ -8,11 +8,14 @@ use Hypervel\Data\Contracts\BaseData; use Hypervel\Data\Contracts\BaseDataCollectable; use Hypervel\Data\Data; +use Hypervel\Data\Enums\DataTypeKind; use Hypervel\Data\Normalizers\Normalized\UnknownProperty; use Hypervel\Data\Support\Creation\AutoLazyReplayMode; use Hypervel\Data\Support\Creation\ConstructionState; use Hypervel\Data\Support\Creation\CreationContext; +use Hypervel\Data\Support\Types\NamedType; use Hypervel\Pagination\Paginator; +use Hypervel\Support\Collection; use Hypervel\Tests\TestCase; use stdClass; use Traversable; @@ -213,6 +216,34 @@ public function testRecordsSparseRawKeyItemOverrides(): void $this->assertArrayNotHasKey('same.item', $posts['items']); } + public function testRecordsSparseTypeSelections(): void + { + $array = new NamedType('array', true, DataTypeKind::Array); + $collection = new NamedType(Collection::class, false, DataTypeKind::Enumerable); + $state = $this->state(); + $state->enterProperty('posts', ['posts']); + + foreach ([0 => $array, 1 => $array, 2 => $collection, 3 => null] as $item => $type) { + $state->enterItem($item); + $state->recordSelectedType('tags', $type); + + $this->assertSame($type, $state->selectedType('tags')); + + $state->leave(); + } + + $state->leave(); + $posts = $state->structure()['children']['posts']; + + $this->assertFalse($posts['uniform']); + $this->assertSame([2, 3], array_keys($posts['items'])); + + $state->recordSelectedType('tags', $array); + $state->resetNodeStructure(); + + $this->assertNull($state->selectedType('tags')); + } + /** * Test a nested difference latches every enclosing collection. */ diff --git a/tests/Data/Support/Creation/CreationContextFactoryTest.php b/tests/Data/Support/Creation/CreationContextFactoryTest.php new file mode 100644 index 0000000000..01e9ae8a29 --- /dev/null +++ b/tests/Data/Support/Creation/CreationContextFactoryTest.php @@ -0,0 +1,186 @@ +get(); + + $this->assertSame(SimpleData::class, $context->dataClass); + $this->assertSame(ValidationStrategy::Always, $context->validationStrategy); + $this->assertTrue($context->mapPropertyNames); + $this->assertFalse($context->disableMagicalCreation); + $this->assertTrue($context->useOptionalValues); + $this->assertSame([], $context->ignoredMagicalMethods); + $this->assertSame([], $context->casts); + } + + #[DataProvider('factoryOptions')] + public function testFactoryOptionsRebuildTheCreateContext(Closure $configure, string $option, mixed $expected): void + { + $factory = SimpleData::factory(); + $before = $factory->get(); + + $configure($factory); + $context = $factory->get(); + + $this->assertNotSame($before, $context); + $this->assertSame($context, $factory->get()); + $this->assertSame($expected, $context->{$option}); + } + + /** + * Get each factory option with the context property it sets and the value it expects. + */ + public static function factoryOptions(): iterable + { + $cast = new StringToUpperCast; + $normalizer = new class implements Normalizer { + /** + * Leave every value for the next normalizer. + */ + public function normalize(mixed $value): ?array + { + return null; + } + }; + $hook = static fn (mixed $value): mixed => $value; + + yield 'is possible to override the validation strategy' => [ + static fn (CreationContextFactory $factory): CreationContextFactory => $factory->validationStrategy(ValidationStrategy::Disabled), + 'validationStrategy', + ValidationStrategy::Disabled, + ]; + + yield 'is possible to disable validation' => [ + static fn (CreationContextFactory $factory): CreationContextFactory => $factory->withoutValidation(), + 'validationStrategy', + ValidationStrategy::Disabled, + ]; + + yield 'is possible to only validate requests' => [ + static fn (CreationContextFactory $factory): CreationContextFactory => $factory->alwaysValidate()->onlyValidateRequests(), + 'validationStrategy', + ValidationStrategy::OnlyRequests, + ]; + + yield 'is possible to always validate' => [ + static fn (CreationContextFactory $factory): CreationContextFactory => $factory->alwaysValidate(), + 'validationStrategy', + ValidationStrategy::Always, + ]; + + yield 'is possible to disable property name mapping' => [ + static fn (CreationContextFactory $factory): CreationContextFactory => $factory->withoutPropertyNameMapping(), + 'mapPropertyNames', + false, + ]; + + yield 'is possible to enable property name mapping' => [ + static fn (CreationContextFactory $factory): CreationContextFactory => $factory->withoutPropertyNameMapping()->withPropertyNameMapping(), + 'mapPropertyNames', + true, + ]; + + yield 'is possible to disable magical creation' => [ + static fn (CreationContextFactory $factory): CreationContextFactory => $factory->withoutMagicalCreation(), + 'disableMagicalCreation', + true, + ]; + + yield 'is possible to enable magical creation' => [ + static fn (CreationContextFactory $factory): CreationContextFactory => $factory->withoutMagicalCreation()->withMagicalCreation(), + 'disableMagicalCreation', + false, + ]; + + yield 'is possible to disable optional values' => [ + static fn (CreationContextFactory $factory): CreationContextFactory => $factory->withoutOptionalValues(), + 'useOptionalValues', + false, + ]; + + yield 'is possible to enable optional values' => [ + static fn (CreationContextFactory $factory): CreationContextFactory => $factory->withoutOptionalValues()->withOptionalValues(), + 'useOptionalValues', + true, + ]; + + yield 'is possible to set ignored magical methods' => [ + static fn (CreationContextFactory $factory): CreationContextFactory => $factory->ignoreMagicalMethod('foo', 'bar'), + 'ignoredMagicalMethods', + ['foo', 'bar'], + ]; + + yield 'is possible to add a cast' => [ + static fn (CreationContextFactory $factory): CreationContextFactory => $factory->withCast('string', StringToUpperCast::class), + 'casts', + ['string' => StringToUpperCast::class], + ]; + + yield 'is possible to add a cast collection' => [ + static fn (CreationContextFactory $factory): CreationContextFactory => $factory + ->withCast(Stringable::class, StringToUpperCast::class) + ->withCastCollection(['string' => $cast]), + 'casts', + [Stringable::class => StringToUpperCast::class, 'string' => $cast], + ]; + + yield 'is possible to add normalizers' => [ + static fn (CreationContextFactory $factory): CreationContextFactory => $factory->withNormalizers($normalizer), + 'normalizers', + [$normalizer], + ]; + + foreach ([ + 'prepareData' => 'prepareDataHooks', + 'beforeValidation' => 'beforeValidationHooks', + 'beforeRules' => 'beforeRulesHooks', + 'afterRules' => 'afterRulesHooks', + 'withValidator' => 'withValidatorHooks', + 'afterValidation' => 'afterValidationHooks', + 'beforeCreation' => 'beforeCreationHooks', + 'afterCreation' => 'afterCreationHooks', + ] as $method => $option) { + yield "is possible to add a {$method} hook" => [ + static fn (CreationContextFactory $factory): CreationContextFactory => $factory->{$method}($hook), + $option, + [$hook], + ]; + } + } + + /** + * Validate every source by default. + */ + protected function alwaysValidateByDefault(Application $app): void + { + $app->make('config')->set('data.validation_strategy', ValidationStrategy::Always->value); + } +} diff --git a/tests/Data/Support/Creation/DataCollectTest.php b/tests/Data/Support/Creation/DataCollectTest.php index 3552abe6ba..41b1a5dbfb 100644 --- a/tests/Data/Support/Creation/DataCollectTest.php +++ b/tests/Data/Support/Creation/DataCollectTest.php @@ -13,6 +13,7 @@ use Hypervel\Data\Data; use Hypervel\Data\DataCollection; use Hypervel\Data\DataServiceProvider; +use Hypervel\Data\Exceptions\CannotCreateDataCollectable; use Hypervel\Data\PaginatedDataCollection; use Hypervel\Database\Eloquent\Collection as EloquentCollection; use Hypervel\Database\Eloquent\Model; @@ -51,6 +52,21 @@ public function testCollectsArraysAndExplicitDataCollectionsWithKeysPreserved(): $this->assertSame(['first', 'second'], array_keys($collection->items())); } + public function testNullCollectsOnlyIntoAnExplicitNonPaginatorTarget(): void + { + $this->assertSame([], RootCollectData::collect(null, 'array')); + $this->assertEquals(new Collection, RootCollectData::collect(null, Collection::class)); + $this->assertEquals(new DataCollection(RootCollectData::class, []), RootCollectData::collect(null, DataCollection::class)); + + foreach ([null, Paginator::class] as $into) { + try { + RootCollectData::collect(null, $into); + $this->fail('Expected null without an explicit non-paginator target to be rejected.'); + } catch (CannotCreateDataCollectable) { + } + } + } + public function testCollectPreservesOrdinaryCollectionShapeAndDowngradesEloquentCollections(): void { $collection = RootCollectData::collect(new RootCollectSourceCollection([ diff --git a/tests/Data/Support/Creation/DataCreatorTest.php b/tests/Data/Support/Creation/DataCreatorTest.php index 2d3a80c011..17b8cc4723 100644 --- a/tests/Data/Support/Creation/DataCreatorTest.php +++ b/tests/Data/Support/Creation/DataCreatorTest.php @@ -9,6 +9,9 @@ use DateTime; use DateTimeImmutable; use Hypervel\Container\Attributes\Config; +use Hypervel\Container\Attributes\RouteParameter; +use Hypervel\Contracts\Container\Container; +use Hypervel\Contracts\Container\ContextualAttribute; use Hypervel\Contracts\Foundation\Application; use Hypervel\Data\Attributes\AutoClosureLazy; use Hypervel\Data\Attributes\AutoInertiaDeferred; @@ -19,9 +22,16 @@ use Hypervel\Data\Attributes\DataCollectionOf; use Hypervel\Data\Attributes\MapInputName; use Hypervel\Data\Attributes\PropertyForMorph; +use Hypervel\Data\Attributes\Validation\Exclude; +use Hypervel\Data\Attributes\Validation\ExcludeIf; +use Hypervel\Data\Attributes\Validation\Max; +use Hypervel\Data\Attributes\Validation\Min; +use Hypervel\Data\Attributes\Validation\RequiredIf; use Hypervel\Data\Attributes\WithCast; use Hypervel\Data\Casts\Cast; use Hypervel\Data\Casts\Castable; +use Hypervel\Data\Casts\IterableItemCast; +use Hypervel\Data\Casts\Uncastable; use Hypervel\Data\Contracts\PropertyMorphableData; use Hypervel\Data\Data; use Hypervel\Data\DataCollection; @@ -38,7 +48,6 @@ use Hypervel\Data\Optional; use Hypervel\Data\Resource; use Hypervel\Data\Support\Creation\AutoLazyReplayMode; -use Hypervel\Data\Support\Creation\ConstructionState; use Hypervel\Data\Support\Creation\CreationContext; use Hypervel\Data\Support\Creation\CreationContextFactory; use Hypervel\Data\Support\Creation\CreationMode; @@ -47,22 +56,32 @@ use Hypervel\Data\Support\DataClassRepository; use Hypervel\Data\Support\DataProperty; use Hypervel\Database\Eloquent\Model; +use Hypervel\Foundation\Http\Attributes\FailOnUnknownFields; use Hypervel\Http\Request; use Hypervel\Pagination\Paginator; +use Hypervel\Support\Arr; use Hypervel\Support\Collection; +use Hypervel\Support\Enumerable; use Hypervel\Support\LazyCollection; use Hypervel\Testbench\Attributes\DefineEnvironment; use Hypervel\Testbench\TestCase; +use Hypervel\Tests\Data\Fixtures\Casts\StringToUpperCast; +use Hypervel\Tests\Data\Fixtures\Concerns\BindsRouteParameters; +use Hypervel\Tests\Data\Fixtures\Enums\DummyBackedEnum; +use Hypervel\Tests\Data\Fixtures\SimpleData; +use Hypervel\Validation\ValidationException; use PHPUnit\Framework\Attributes\DataProvider; use ReflectionFunction; +use RuntimeException; use Throwable; use TypeError; use WeakReference; class DataCreatorTest extends TestCase { - // REMOVED: Configurable pipeline, prepareForPipeline(), and inherited-context factory tests; use the fixed engine and fresh factory hooks. - // REMOVED: withOptionalValues()/withoutOptionalValues() tests; Optional declarations always preserve absence. + use BindsRouteParameters; + + // REMOVED: Configurable pipeline tests; use the fixed engine and factory hooks. // REMOVED: Data-specific From* injection tests; Hypervel contextual attributes cover the same outcomes directly. // REMOVED: UnserializeCast tests; serialized request input is not accepted by a built-in cast. @@ -85,11 +104,33 @@ public function testCreatesMappedDataWithCastsAndFixedAbsenceSemantics(): void $this->assertInstanceOf(Optional::class, $data->note); } - public function testFirstSourceContainingAPropertyWinsAndMappingCanBeDisabled(): void + public function testMalformedNumbersFailWithPhpsTypeErrorWithAndWithoutHooks(): void + { + $payload = ['name' => 'Taylor', 'age' => '12abc']; + $rejected = 0; + + foreach ([ + static fn (): BasicCreationData => BasicCreationData::from($payload), + static fn (): BasicCreationData => BasicCreationData::factory() + ->prepareData(static fn (array $data): array => $data) + ->from($payload), + ] as $create) { + try { + $create(); + } catch (TypeError) { + ++$rejected; + } + } + + $this->assertSame(2, $rejected); + $this->assertSame(21, BasicCreationData::from([...$payload, 'age' => ' 21 '])->age); + } + + public function testLaterSourceContainingAPropertyWinsAndMappingCanBeDisabled(): void { - $first = BasicCreationData::from( - ['name' => 'First'], - ['profile' => ['name' => 'Second']], + $later = BasicCreationData::from( + ['name' => 'First', 'nickname' => 'Tay', 'note' => 'kept'], + ['profile' => ['name' => 'Second'], 'nickname' => null, 'note' => Optional::create()], ); $unmapped = BasicCreationData::factory() ->withoutPropertyNameMapping() @@ -98,11 +139,72 @@ public function testFirstSourceContainingAPropertyWinsAndMappingCanBeDisabled(): 'profile' => ['name' => 'Mapped'], ]); - $this->assertSame('First', $first->name); + $this->assertSame('Second', $later->name); + $this->assertNull($later->nickname); + $this->assertSame('kept', $later->note); $this->assertSame('Plain', $unmapped->name); $this->assertNotSame(BasicCreationData::factory(), BasicCreationData::factory()); } + public function testAnExplicitOptionalIsKeptWhenNoSourceSuppliesAValue(): void + { + $data = OptionalDefaultCreationData::from(['note' => Optional::create()], []); + + $this->assertInstanceOf(Optional::class, $data->note); + } + + public function testAPrepareDataHookReturningItsInputChangesNothing(): void + { + $sources = [ + ['name' => 'First', 'meta' => ['a' => 1]], + ['name' => 'Second', 'meta' => ['b' => 2]], + ]; + + $plain = PrepareDataIdentityData::from(...$sources); + $hooked = PrepareDataIdentityData::factory() + ->prepareData(static fn (array $data): array => $data) + ->from(...$sources); + + $this->assertSame(['b' => 2], $plain->meta); + $this->assertSame($plain->toArray(), $hooked->toArray()); + + $mappedCases = [ + 'php name after a dot mapping' => [ + ['profile' => ['name' => 'old', 'bio' => 'kept'], 'user_code' => 'a'], + ['name' => 'new'], + ], + 'dot mapping after a php name' => [ + ['name' => 'old', 'user_code' => 'a'], + ['profile' => ['name' => 'new']], + ], + 'php name after a flat mapping' => [ + ['name' => 'Taylor', 'user_code' => 'old'], + ['code' => 'new'], + ], + ]; + + foreach ($mappedCases as $case => $mappedSources) { + $plain = MappedPrepareDataIdentityData::from(...$mappedSources); + $hooked = MappedPrepareDataIdentityData::factory() + ->prepareData(static fn (array $data): array => $data) + ->from(...$mappedSources); + + $this->assertSame($plain->all(), $hooked->all(), $case); + } + + $received = null; + MappedPrepareDataIdentityData::factory() + ->prepareData(static function (array $data) use (&$received): array { + $received = $data; + + return $data; + }) + ->from(...$mappedCases['php name after a dot mapping']); + + // The selected value moves to the spelling resolution reads first; sibling input remains. + $this->assertSame(['name' => 'new', 'bio' => 'kept'], $received['profile']); + } + public function testFreshDefaultFactoriesShareOnlyTheirImmutableContext(): void { $first = BasicCreationData::factory(); @@ -120,117 +222,6 @@ public function testFreshDefaultFactoriesShareOnlyTheirImmutableContext(): void $this->assertSame($default, BasicCreationData::factory()->get()); } - public function testEveryFactoryMutatorInvalidatesAndRebuildsTheCreateContext(): void - { - $hook = static fn (mixed $value): mixed => $value; - $cases = [ - 'validationStrategy' => [ - static fn (CreationContextFactory $factory) => $factory->validationStrategy(ValidationStrategy::Disabled), - fn (CreationContext $context) => $this->assertSame(ValidationStrategy::Disabled, $context->validationStrategy), - ], - 'withoutValidation' => [ - static fn (CreationContextFactory $factory) => $factory->withoutValidation(), - fn (CreationContext $context) => $this->assertSame(ValidationStrategy::Disabled, $context->validationStrategy), - ], - 'onlyValidateRequests' => [ - static fn (CreationContextFactory $factory) => $factory->onlyValidateRequests(), - fn (CreationContext $context) => $this->assertSame(ValidationStrategy::OnlyRequests, $context->validationStrategy), - ], - 'alwaysValidate' => [ - static fn (CreationContextFactory $factory) => $factory->alwaysValidate(), - fn (CreationContext $context) => $this->assertSame(ValidationStrategy::Always, $context->validationStrategy), - ], - 'withPropertyNameMapping' => [ - static fn (CreationContextFactory $factory) => $factory->withPropertyNameMapping(), - fn (CreationContext $context) => $this->assertTrue($context->mapPropertyNames), - ], - 'withoutPropertyNameMapping' => [ - static fn (CreationContextFactory $factory) => $factory->withoutPropertyNameMapping(), - fn (CreationContext $context) => $this->assertFalse($context->mapPropertyNames), - ], - 'withoutMagicalCreation' => [ - static fn (CreationContextFactory $factory) => $factory->withoutMagicalCreation(), - fn (CreationContext $context) => $this->assertTrue($context->disableMagicalCreation), - ], - 'withMagicalCreation' => [ - static fn (CreationContextFactory $factory) => $factory->withMagicalCreation(), - fn (CreationContext $context) => $this->assertFalse($context->disableMagicalCreation), - ], - 'ignoreMagicalMethod' => [ - static fn (CreationContextFactory $factory) => $factory->ignoreMagicalMethod('fromString'), - fn (CreationContext $context) => $this->assertSame(['fromString'], $context->ignoredMagicalMethods), - ], - 'withCast' => [ - static fn (CreationContextFactory $factory) => $factory->withCast(CreationSource::class, CreationIdentifierCast::class), - fn (CreationContext $context) => $this->assertSame( - [CreationSource::class => CreationIdentifierCast::class], - $context->casts, - ), - ], - 'withCastCollection' => [ - static fn (CreationContextFactory $factory) => $factory->withCastCollection([ - CreationSource::class => CreationIdentifierCast::class, - ]), - fn (CreationContext $context) => $this->assertSame( - [CreationSource::class => CreationIdentifierCast::class], - $context->casts, - ), - ], - 'withNormalizers' => [ - static fn (CreationContextFactory $factory) => $factory->withNormalizers(CreationSourceNormalizer::class), - fn (CreationContext $context) => $this->assertSame( - [CreationSourceNormalizer::class], - $context->normalizers, - ), - ], - 'prepareData' => [ - static fn (CreationContextFactory $factory) => $factory->prepareData($hook), - fn (CreationContext $context) => $this->assertSame([$hook], $context->prepareDataHooks), - ], - 'beforeValidation' => [ - static fn (CreationContextFactory $factory) => $factory->beforeValidation($hook), - fn (CreationContext $context) => $this->assertSame([$hook], $context->beforeValidationHooks), - ], - 'beforeRules' => [ - static fn (CreationContextFactory $factory) => $factory->beforeRules($hook), - fn (CreationContext $context) => $this->assertSame([$hook], $context->beforeRulesHooks), - ], - 'afterRules' => [ - static fn (CreationContextFactory $factory) => $factory->afterRules($hook), - fn (CreationContext $context) => $this->assertSame([$hook], $context->afterRulesHooks), - ], - 'withValidator' => [ - static fn (CreationContextFactory $factory) => $factory->withValidator($hook), - fn (CreationContext $context) => $this->assertSame([$hook], $context->withValidatorHooks), - ], - 'afterValidation' => [ - static fn (CreationContextFactory $factory) => $factory->afterValidation($hook), - fn (CreationContext $context) => $this->assertSame([$hook], $context->afterValidationHooks), - ], - 'beforeCreation' => [ - static fn (CreationContextFactory $factory) => $factory->beforeCreation($hook), - fn (CreationContext $context) => $this->assertSame([$hook], $context->beforeCreationHooks), - ], - 'afterCreation' => [ - static fn (CreationContextFactory $factory) => $factory->afterCreation($hook), - fn (CreationContext $context) => $this->assertSame([$hook], $context->afterCreationHooks), - ], - ]; - - foreach ($cases as $name => [$mutate, $verify]) { - $factory = BasicCreationData::factory(); - $before = $factory->get(); - - $this->assertSame($before, $factory->get(), $name); - $mutate($factory); - $after = $factory->get(); - - $this->assertNotSame($before, $after, $name); - $this->assertSame($after, $factory->get(), $name); - $verify($after); - } - } - public function testFromRetainsTheLateStaticFactoryBoundary(): void { FactoryOverrideCreationData::$factoryCalls = 0; @@ -451,19 +442,6 @@ public function testDirectArrayCreationRejectsSuppliedComputedAndVirtualValues() } } - /** - * Test a named factory is invoked once before an exact array exit. - */ - public function testDirectArrayCreationDoesNotRematchNamedFactories(): void - { - DirectNamedFactoryCreationData::$calls = 0; - - $data = DirectNamedFactoryCreationData::from('9'); - - $this->assertSame(9, $data->id); - $this->assertSame(1, DirectNamedFactoryCreationData::$calls); - } - /** * Test the direct exit cannot replace an array-returning engine mode. */ @@ -658,18 +636,27 @@ public function testPreservesRawCollectionKeysAcrossFillValidationAndConstructio $payload = [ 'items' => [ 'tenant.eu' => ['profile' => ['name' => 'Europe']], - 'tenant' => ['name' => 'Global'], + 'tenant' => ['profile' => ['name' => 'Global']], ], ]; $rules = MappedItemListCreationData::getValidationRules($payload); $data = MappedItemListCreationData::validateAndCreate($payload); - $this->assertArrayHasKey('items.tenant\.eu.profile.name', $rules); - $this->assertArrayHasKey('items.tenant.name', $rules); + $this->assertArrayHasKey('items.*.profile.name', $rules); $this->assertSame(['tenant.eu', 'tenant'], array_keys($data->items)); $this->assertSame('Europe', $data->items['tenant.eu']->name); $this->assertSame('Global', $data->items['tenant']->name); + + // The wildcard rule reaches the dotted key as one item. + try { + MappedItemListCreationData::validateAndCreate([ + 'items' => ['tenant.eu' => ['profile' => ['name' => 123]]], + ]); + $this->fail('Expected the dotted item to fail validation.'); + } catch (ValidationException $exception) { + $this->assertSame(['items.tenant.eu.profile.name'], array_keys($exception->errors())); + } } public function testPreservesLazyCollectionTraversalWhenValidationIsNotRunning(): void @@ -742,13 +729,6 @@ public function testAutomaticLazyReplayIsLimitedToStructuralProperties(): void $this->assertNotSame($paginator, $children); $this->assertSame(2, $children->currentPage()); $this->assertSame(8, $children->items()[0]->id); - - RecordingAutoLazy::reset(); - AutoLazyNamedFactoryData::$source = null; - $named = AutoLazyNamedFactoryData::from('named'); - - $this->assertSame(AutoLazyNamedFactoryData::$source, RecordingAutoLazy::$payloads['title']); - $this->assertSame('named', $named->title->resolve()); } public function testAutomaticLazyReplayConstructsExactAndCoercingChildrenOnce(): void @@ -1021,20 +1001,18 @@ public function testDtoAndResourceUseTheSameFixedConstructionEngine(): void $this->assertSame(2, $resource->id); } - public function testNamedFactoriesCanReturnTheTargetOrAnotherNormalizableValue(): void + public function testNamedFactoriesMustReturnTheRequestedObject(): void { $direct = NamedFactoryCreationData::factory() ->beforeCreation(fn (): never => throw new CannotCreateData('should not run')) ->from('Taylor'); - $continued = NamedFactoryCreationData::factory() - ->beforeCreation(fn (array $properties): array => [ - ...$properties, - 'value' => strtoupper($properties['value']), - ]) - ->from(42); $this->assertSame('direct:Taylor', $direct->value); - $this->assertSame('NUMBER:42', $continued->value); + + $this->expectException(CannotCreateData::class); + $this->expectExceptionMessageIsOrContains('the named factory [fromNumber()] returned [array] instead of an instance of'); + + NamedFactoryCreationData::from(42); } public function testNamedPayloadMatchesANamedFactory(): void @@ -1051,17 +1029,6 @@ public function testNamedPayloadWithoutAFactoryUsesOrdinaryCreation(): void $this->assertSame(7, $data->id); } - public function testNamedPayloadRetainsAutomaticLazyFactoryProvenance(): void - { - RecordingAutoLazy::reset(); - AutoLazyNamedFactoryData::$source = null; - - $data = AutoLazyNamedFactoryData::from(title: 'named'); - - $this->assertSame(AutoLazyNamedFactoryData::$source, RecordingAutoLazy::$payloads['title']); - $this->assertSame('named', $data->title->resolve()); - } - public function testNamedPayloadRetainsAutomaticLazySourceWithoutAFactory(): void { RecordingAutoLazy::reset(); @@ -1088,59 +1055,203 @@ public function testNamedFactoryDependenciesUseContainerCallWithoutMethodBinding ); } - public function testContextualConstructorValuesOverrideClientPayload(): void + public function testContextualValuesAreConvertedLikeUnvalidatedInput(): void + { + $request = $this->bindRouteParameters([ + 'id' => '123', + 'status' => 'foo', + 'at' => '2026-10-04T10:00:00+00:00', + 'child' => ['string' => 'nested'], + 'choice' => ['string' => 'selected'], + 'items' => [['string' => 'first'], ['string' => 'second']], + ]); + + $data = ContextualConversionCreationData::from($request); + + $this->assertSame(123, $data->id); + $this->assertSame(DummyBackedEnum::FOO, $data->status); + $this->assertEquals(new DateTimeImmutable('2026-10-04T10:00:00+00:00'), $data->at); + $this->assertEquals(new SimpleData('nested'), $data->child); + $this->assertEquals(new SimpleData('selected'), $data->choice); + $this->assertEquals([new SimpleData('first'), new SimpleData('second')], $data->items); + $this->assertNull($data->missing); + } + + public function testContextualValuesWinOverHooksAndResolveOnceInTheirBuildContext(): void { config()->set('app.name', 'Server'); + $this->app->bind(ContextualCreationContract::class, ContextualCreationDefault::class); + $this->app->when(ContextualResolutionCreationData::class) + ->needs(ContextualCreationContract::class) + ->give(ContextualCreationForData::class); + $parameterCallbacks = 0; + $classCallbacks = 0; + $this->app->afterResolvingAttribute( + ContextualCreationDependency::class, + function () use (&$parameterCallbacks): void { + ++$parameterCallbacks; + }, + ); + $this->app->afterResolvingAttribute( + ContextualCreationMarker::class, + function () use (&$classCallbacks): void { + ++$classCallbacks; + }, + ); - $data = ContextualCreationData::from([ - 'id' => '7', - 'name' => 'Client', - ]); + $data = ContextualResolutionCreationData::factory() + ->beforeCreation(static fn (array $properties): array => [...$properties, 'dependency' => 'hook']) + ->from(['dependency' => 'client']); - $this->assertSame(7, $data->id); - $this->assertSame('Server', $data->name); + $this->assertSame(ContextualCreationForData::class, $data->dependency); + $this->assertSame('Server', $data->label); + $this->assertSame(1, $parameterCallbacks); + $this->assertSame(1, $classCallbacks); } - public function testResolvesAbstractPropertyMorphsBeforeFillingConcreteProperties(): void + public function testValidatedContextualValuesUseTheObjectsRules(): void { - $shape = ShapeCreationData::from([ - 'type' => 'circle', - 'radius' => '12', + ValidatedContextualValue::$values = ['id' => 500, 'owner' => null, 'child' => 50]; + + try { + ValidatedContextualData::validate([ + 'id' => 1, + 'owner_id' => 2, + 'child' => ['value' => 1, 'name' => 'Child'], + ]); + $this->fail('Expected the contextual values to be validated.'); + } catch (ValidationException $exception) { + $this->assertEqualsCanonicalizing(['id', 'owner_id', 'child.value'], array_keys($exception->errors())); + } + + ValidatedContextualValue::$values = ['id' => 5, 'owner' => 3, 'child' => 1]; + + try { + // The rule referencing the contextual property sees the server value, not the client's. + ValidatedContextualData::validate(['id' => 7]); + $this->fail('Expected the dependent rule to read the contextual value.'); + } catch (ValidationException $exception) { + $this->assertSame(['note'], array_keys($exception->errors())); + } + + $data = ValidatedContextualData::validateAndCreate([ + 'id' => 7, + 'note' => 'Checked', + 'child' => ['value' => 9, 'name' => 'Child'], ]); - $this->assertInstanceOf(CircleCreationData::class, $shape); - $this->assertSame(12, $shape->radius); + $this->assertSame(5, $data->id); + $this->assertSame(3, $data->ownerId); + $this->assertSame(1, $data->child?->value); } - public function testResolvesMorphsFromBackedEnumsDefaultsAndNestedCollections(): void + public function testHooksCannotReplaceValidatedContextualValues(): void { - $default = DefaultShapeCreationData::from(['radius' => '3']); - $mapped = DefaultShapeCreationData::from([ - 'status' => 'active', - 'radius' => '4', - ]); - $nested = ShapeListCreationData::from([ - 'shapes' => [ - [ - 'type' => 'circle', - 'radius' => '5', - ], - [ - 'type' => 'square', - 'side' => '6', - ], - ], + ValidatedContextualValue::$resolutions = []; + ValidatedContextualValue::$values = ['id' => 5, 'owner' => 3]; + $validated = null; + + $data = ValidatedContextualData::factory() + ->alwaysValidate() + ->beforeValidation(static function (array $payload): array { + unset($payload['owner_id']); + + return [...$payload, 'id' => 1]; + }) + ->afterValidation(static function (array $payload) use (&$validated): array { + $validated = $payload; + + return [...$payload, 'id' => 2]; + }) + ->beforeCreation(static fn (array $properties): array => [...$properties, 'id' => 3, 'ownerId' => 4]) + ->from(['note' => 'Checked']); + + $this->assertSame(5, $validated['id']); + $this->assertSame(3, $validated['owner_id']); + $this->assertSame(5, $data->id); + $this->assertSame(3, $data->ownerId); + // The missing child is never prepared, so its contextual value is not resolved. + $this->assertSame(['id' => 1, 'owner' => 1], ValidatedContextualValue::$resolutions); + } + + public function testExcludedContextualValuesAreNotRestored(): void + { + ValidatedContextualValue::$resolutions = []; + ValidatedContextualValue::$values = ['defaulted' => 'server', 'nullable' => 'server', 'service' => 'service']; + + $data = ExcludedContextualData::validateAndCreate([ + 'skip' => true, + 'defaulted' => 'client', + 'nullable' => 'client', ]); - $this->assertInstanceOf(DefaultCircleCreationData::class, $default); - $this->assertSame(CreationStatus::Active, $default->status); - $this->assertSame(3, $default->radius); - $this->assertInstanceOf(DefaultCircleCreationData::class, $mapped); - $this->assertSame(4, $mapped->radius); - $this->assertInstanceOf(CircleCreationData::class, $nested->shapes[0]); - $this->assertSame(5, $nested->shapes[0]->radius); - $this->assertInstanceOf(SquareCreationData::class, $nested->shapes[1]); - $this->assertSame(6, $nested->shapes[1]->side); + $this->assertSame('default', $data->defaulted); + $this->assertNull($data->nullable); + $this->assertSame('service', $data->serviceLabel); + $this->assertSame(['nullable' => 1, 'defaulted' => 1, 'service' => 1], ValidatedContextualValue::$resolutions); + // A constructor-only contextual parameter is not a validation field. + $this->assertArrayNotHasKey('service', ExcludedContextualData::validate(['skip' => false])); + + ValidatedContextualValue::$values['required'] = 'server'; + + $this->expectException(CannotCreateData::class); + + RequiredExcludedContextualData::validateAndCreate(['skip' => true]); + } + + public function testHookMorphingAPreparedNodeResolvesTheNewClassesContextualValues(): void + { + ValidatedContextualValue::$values = ['first' => 1, 'second' => 50]; + $factory = ContextualMorphData::factory() + ->alwaysValidate() + ->beforeValidation(static fn (array $payload): array => [...$payload, 'type' => 'second']); + + try { + $factory->from(['type' => 'first']); + $this->fail('Expected the new class to validate its own contextual value.'); + } catch (ValidationException $exception) { + $this->assertSame(['value'], array_keys($exception->errors())); + } + + ValidatedContextualValue::$resolutions = []; + ValidatedContextualValue::$values['second'] = 2; + + $data = $factory->from(['type' => 'first']); + + $this->assertInstanceOf(SecondContextualMorphData::class, $data); + $this->assertSame(2, $data->value); + $this->assertSame(['first' => 1, 'second' => 1], ValidatedContextualValue::$resolutions); + } + + public function testHookMorphingAPreparedItemResolvesTheNewClassesContextualValues(): void + { + ValidatedContextualValue::$values = ['first' => 1, 'second' => 50]; + $factory = ContextualMorphListData::factory() + ->alwaysValidate() + ->beforeValidation(static function (array $payload): array { + $payload['items'][1]['type'] = 'second'; + + return $payload; + }); + $input = ['items' => [['type' => 'first'], ['type' => 'first']]]; + + try { + $factory->from($input); + $this->fail('Expected the new item class to validate its own contextual value.'); + } catch (ValidationException $exception) { + $this->assertSame(['items.1.value'], array_keys($exception->errors())); + } + + ValidatedContextualValue::$resolutions = []; + ValidatedContextualValue::$values['second'] = 2; + + $data = $factory->from($input); + + $this->assertInstanceOf(FirstContextualMorphData::class, $data->items[0]); + $this->assertSame(1, $data->items[0]->value); + $this->assertInstanceOf(SecondContextualMorphData::class, $data->items[1]); + $this->assertSame(2, $data->items[1]->value); + $this->assertSame(['first' => 2, 'second' => 1], ValidatedContextualValue::$resolutions); } public function testResolvesIntegerBackedMorphFromNumericString(): void @@ -1228,6 +1339,14 @@ public function testRejectsUnresolvedAndInvalidPropertyMorphs(): void } } + public function testRejectsInputNoNormalizerCanRead(): void + { + $this->expectException(CannotCreateData::class); + $this->expectExceptionMessageIsOrContains('no normalizer accepted the value'); + + ChildCreationData::from(42); + } + public function testRejectsAmbiguousDataObjectUnionsWithoutAnExplicitCast(): void { $this->expectException(CannotCreateData::class); @@ -1236,10 +1355,29 @@ public function testRejectsAmbiguousDataObjectUnionsWithoutAnExplicitCast(): voi AmbiguousCreationData::from(['child' => ['id' => 1]]); } - public function testRejectsAmbiguousDataCollectableUnionsWithoutAnExplicitCast(): void + public function testDataObjectUnionsKeepAcceptedArraysAndBuildOtherValues(): void + { + $kept = DataObjectUnionCreationData::validateAndCreate(['child' => ['name' => 'kept']]); + $built = DataObjectUnionCreationData::validateAndCreate([ + 'child' => (object) ['external_name' => 'Taylor'], + 'containers' => (object) ['external_name' => 'Abigail'], + ]); + $items = DataObjectUnionListData::validateAndCreate(['items' => [ + ['child' => (object) ['external_name' => 'Taylor']], + ['child' => ['name' => 'kept']], + ]]); + + $this->assertSame(['name' => 'kept'], $kept->child); + $this->assertSame('Taylor', $built->child->name); + $this->assertSame('Abigail', $built->containers->name); + $this->assertSame('Taylor', $items->items[0]->child->name); + $this->assertSame(['name' => 'kept'], $items->items[1]->child); + } + + public function testRejectsAmbiguousContainerUnionsWithoutAnExplicitCast(): void { $this->expectException(CannotCreateData::class); - $this->expectExceptionMessageIsOrContains('ambiguous data-collectable union'); + $this->expectExceptionMessageIsOrContains('ambiguous container union'); AmbiguousDataCollectableCreationData::from([ 'children' => [['id' => 1]], @@ -1261,17 +1399,18 @@ public function testAcceptsFinishedContainersThroughAnyDataCollectableUnionArm() $this->assertSame($package, $packageData->children); } - public function testRejectsFinishedLookingUnionContainerWithTheWrongItems(): void + public function testConvertsItemsOfAContainerOnlyOneUnionTypeAccepts(): void { - $this->expectException(CannotCreateData::class); - $this->expectExceptionMessageIsOrContains('ambiguous data-collectable union'); - - AmbiguousDataCollectableCreationData::from([ + $data = AmbiguousDataCollectableCreationData::from([ 'children' => new Collection([new AlternateChildCreationData(1)]), ]); + + $this->assertInstanceOf(Collection::class, $data->children); + $this->assertInstanceOf(ChildCreationData::class, $data->children->first()); + $this->assertSame(1, $data->children->first()->id); } - public function testExplicitCastOwnsAnAmbiguousDataCollectableUnion(): void + public function testExplicitCastOwnsAnAmbiguousContainerUnion(): void { $data = CastedAmbiguousDataCollectableCreationData::from(['children' => '7']); @@ -1280,1262 +1419,2779 @@ public function testExplicitCastOwnsAnAmbiguousDataCollectableUnion(): void $this->assertSame(7, $data->children->first()->id); } - public function testUnrelatedUnionArmPassesThroughAmbiguousDataCollectableTypes(): void + public function testTypedContainerUnionsCastItemsInTheContainerTheValueSelects(): void { - $data = AmbiguousDataCollectableCreationData::from(['children' => 'unchanged']); + $arrays = TypedContainerUnionCreationData::from([ + 'both' => ['a' => '1'], + 'shorthand' => ['2'], + 'arrayOnly' => ['3'], + ]); + $collections = TypedContainerUnionCreationData::from([ + 'both' => new Collection(['a' => '1']), + 'shorthand' => new Collection(['2']), + 'arrayOnly' => new Collection(['3']), + ]); - $this->assertSame('unchanged', $data->children); + $this->assertSame(['a' => 1], $arrays->both); + $this->assertSame([2], $arrays->shorthand); + $this->assertSame([3], $arrays->arrayOnly); + $this->assertInstanceOf(Collection::class, $collections->both); + $this->assertSame(['a' => 1], $collections->both->all()); + $this->assertInstanceOf(Collection::class, $collections->shorthand); + $this->assertSame([2], $collections->shorthand->all()); + $this->assertInstanceOf(Collection::class, $collections->arrayOnly); + $this->assertSame([3], $collections->arrayOnly->all()); } - public function testClassNormalizersCustomCastsAndCreationHooksShareOneOperation(): void + public function testRejectsValuesNoSingleContainerTypeAccepts(): void { - $data = CustomizedCreationData::factory() - ->prepareData(fn (array $input): array => [ - ...$input, - 'label' => $input['label'] . '-prepared', - ]) - ->afterCreation(function (CustomizedCreationData $data): CustomizedCreationData { - $data->label = strtoupper($data->label); - - return $data; - }) - ->from(new CreationSource('item', 'identifier')); - - $this->assertSame(123, $data->id); - $this->assertSame('CAST:ITEM-PREPARED', $data->label); + foreach ([ + static fn (): OverlappingContainerUnionCreationData => OverlappingContainerUnionCreationData::from([ + 'values' => new Collection([1]), + ]), + static fn (): TypedContainerUnionCreationData => TypedContainerUnionCreationData::from([ + 'both' => new LazyCollection(['1']), + 'shorthand' => [], + 'arrayOnly' => [], + ]), + ] as $create) { + try { + $create(); + $this->fail('Expected an ambiguous container union.'); + } catch (CannotCreateData $exception) { + $this->assertStringContainsString('ambiguous container union', $exception->getMessage()); + } + } } - /** - * Test configured global normalizers participate in construction. - */ - #[DefineEnvironment('withConfiguredNormalizer')] - public function testConfiguredGlobalNormalizersParticipateInConstruction(): void + public function testMixedContainerUnionsSelectTheDataOrPlainTypeByContainer(): void { - $data = ConfiguredNormalizerData::from(new CreationSource('item', 'identifier')); + $plain = MixedContainerUnionCreationData::from(['children' => ['a' => '1', 'b' => '2']]); + $data = MixedContainerUnionCreationData::from(['children' => new Collection([['id' => '3']])]); - $this->assertSame('identifier', $data->id); - $this->assertSame('item', $data->label); + $this->assertSame(['a' => 1, 'b' => 2], $plain->children); + $this->assertInstanceOf(Collection::class, $data->children); + $this->assertInstanceOf(ChildCreationData::class, $data->children->first()); + $this->assertSame(3, $data->children->first()->id); + $this->assertSame(['children' => ['a' => 1, 'b' => 2]], $plain->toArray()); + $this->assertSame(['children' => [['id' => 3]]], $data->toArray()); + + // The plain array type has no item data rules, so its integers pass validation. + $this->assertSame([1, 2], MixedContainerUnionCreationData::validateAndCreate(['children' => [1, 2]])->children); + + try { + MixedContainerUnionCreationData::validateAndCreate(['children' => new Collection([['id' => 'x']])]); + $this->fail('Expected the selected data collection rules to run.'); + } catch (ValidationException $exception) { + $this->assertSame(['children.0.id'], array_keys($exception->errors())); + } } - public function testRejectsSuppliedComputedValuesAndInvalidAfterCreationResults(): void + public function testCollectionItemsSelectTheirOwnContainerTypes(): void { try { - ComputedCreationData::from(['id' => 1, 'summary' => 'client']); - $this->fail('Expected computed input to be rejected.'); - } catch (CannotSetComputedValue $exception) { - $this->assertStringContainsString('ComputedCreationData::$summary', $exception->getMessage()); + MixedContainerUnionListData::validateAndCreate(['items' => [ + ['children' => [1, 2]], + ['children' => new Collection([['id' => 'x']])], + ]]); + $this->fail('Expected only the second item to use the data collection rules.'); + } catch (ValidationException $exception) { + $this->assertSame(['items.1.children.0.id'], array_keys($exception->errors())); } - $this->expectException(CannotCreateData::class); - $this->expectExceptionMessageIsOrContains('instead of an instance of'); + $data = MixedContainerUnionListData::validateAndCreate(['items' => [ + ['children' => [1, 2]], + ['children' => new Collection([['id' => '3']])], + ]]); - BasicCreationData::factory() - ->afterCreation(fn (): ChildCreationData => new ChildCreationData(1)) - ->from(['name' => 'Taylor']); + $this->assertSame([1, 2], $data->items[0]->children); + $this->assertSame(3, $data->items[1]->children->first()->id); } - /** - * Capture a creation value or its exact failure contract. - * - * @return array{result: 'exception', class: class-string, message: string}|array{result: 'value', value: mixed} - */ - protected function captureCreationOutcome(Closure $create): array + public function testValidationHooksReselectTheContainerType(): void { - try { - return ['result' => 'value', 'value' => $create()]; - } catch (Throwable $exception) { - return [ - 'result' => 'exception', - 'class' => $exception::class, - 'message' => $exception->getMessage(), - ]; - } + $data = MixedContainerUnionCreationData::factory() + ->alwaysValidate() + ->beforeValidation(static fn (array $payload): array => [ + ...$payload, + 'children' => new Collection([['id' => '4']]), + ]) + ->from(['children' => [1, 2]]); + $plain = MixedContainerUnionCreationData::factory() + ->alwaysValidate() + ->beforeValidation(static fn (array $payload): array => [...$payload, 'children' => ['5']]) + ->from(['children' => new Collection([['id' => 'x']])]); + + $this->assertSame(4, $data->children->first()->id); + $this->assertSame([5], $plain->children); } - /** - * Configure a global data normalizer. - */ - protected function withConfiguredNormalizer(Application $app): void + public function testAutomaticLazyContainerUnionsKeepTheirSelection(): void { - $app->make('config')->set('data.normalizers', [CreationSourceNormalizer::class]); + $plain = AutoLazyContainerUnionCreationData::from(['children' => ['1']]); + $data = AutoLazyContainerUnionCreationData::from(['children' => new Collection([['id' => '2']])]); + + $this->assertSame([1], $plain->children->resolve()); + $this->assertSame(2, $data->children->resolve()->first()->id); } -} -class BasicCreationData extends Data -{ - public function __construct( - #[MapInputName('profile.name')] - public string $name, - public ?string $nickname, - public string|Optional $note, - public int $age = 18, - ) { + #[DefineEnvironment('withStringToUpperCast')] + public function testConfiguredItemCastsApplyToTheSelectedContainerType(): void + { + $array = ConfiguredItemCastUnionCreationData::from(['names' => ['a']]); + $collection = ConfiguredItemCastUnionCreationData::from(['names' => new Collection(['b'])]); + + $this->assertSame(['A'], $array->names); + $this->assertSame(['B'], $collection->names->all()); } -} -class FactoryOverrideCreationData extends Data + public function testUserCastsReceiveTheDeclaredPropertyValues(): void + { + RecordingInputsCast::$properties = null; + + $data = CastInputsData::from([ + 'first' => '5', + 'recorded' => 'value', + 'later_value' => 'raw later', + 'count' => '7', + ]); + $properties = RecordingInputsCast::$properties; + + $this->assertSame( + ['first', 'recorded', 'later', 'count', 'optional', 'nullable', 'default'], + array_keys($properties), + ); + $this->assertSame(5, $properties['first']); + $this->assertSame('value', $properties['recorded']); + $this->assertSame('raw later', $properties['later']); + $this->assertSame('7', $properties['count']); + $this->assertInstanceOf(Optional::class, $properties['optional']); + $this->assertNull($properties['nullable']); + $this->assertSame($data->default, $properties['default']); + + $leading = CastInputsLeadingDefaultData::from(['recorded' => 'value']); + + $this->assertSame($leading->default, RecordingInputsCast::$properties['default']); + } + + public function testIterableItemCastsShareTheDeclaredPropertyValues(): void + { + RecordingItemInputsCast::$received = []; + + $data = CastInputsIterableData::from(['name' => 'Taylor', 'tags' => ['a', 'b']]); + + $this->assertSame(['A', 'B'], $data->tags); + $this->assertSame( + array_fill(0, 2, ['name' => 'Taylor', 'tags' => ['a', 'b']]), + RecordingItemInputsCast::$received, + ); + } + + public function testDeferredUserCastsSeeTheValuesFromWhenTheyWereDeferred(): void + { + RecordingInputsCast::$properties = null; + + $data = CastInputsLazyData::from(['first' => '5', 'recorded' => 'value']); + + $this->assertNull(RecordingInputsCast::$properties); + $this->assertSame('value', $data->recorded->resolve()); + $this->assertSame(['first' => 5, 'recorded' => 'value'], RecordingInputsCast::$properties); + + RecordingInputsCast::$properties = null; + + $factoryCast = FactoryCastInputsLazyData::factory() + ->withCast('string', RecordingInputsCast::class) + ->from(['first' => '5', 'recorded' => 'value']); + + $this->assertSame('value', $factoryCast->recorded->resolve()); + $this->assertSame(['first' => 5, 'recorded' => 'value'], RecordingInputsCast::$properties); + } + + public function testCastOwnedPropertiesReceiveTheirInputBeforeChildCreation(): void + { + // The child's throwing fromString() would run if the child were created before the cast. + $data = CastOwnedParentData::from(['child' => 'Taylor']); + + $this->assertSame('cast:Taylor', $data->child->name); + } + + public function testDeclinedCastsBuildChildrenFromValidatedConcreteInput(): void + { + CastOwnedChildCast::$received = null; + + $data = CastOwnedParentData::validateAndCreate([ + 'child' => ['external_name' => 'Taylor', 'undeclared' => 'dropped'], + ]); + + $this->assertSame(['external_name' => 'Taylor'], CastOwnedChildCast::$received); + $this->assertSame('Taylor', $data->child->name); + + $shapes = CastOwnedShapesData::validateAndCreate([ + 'shape' => ['type' => 'square', 'code' => 'abc'], + 'shapes' => [['type' => 'square', 'code' => 'def']], + ]); + + $this->assertInstanceOf(CastOwnedSquareData::class, $shapes->shape); + $this->assertSame('abc', $shapes->shape->code); + $this->assertInstanceOf(CastOwnedSquareData::class, $shapes->shapes[0]); + $this->assertSame('def', $shapes->shapes[0]->code); + + try { + CastOwnedShapesData::validateAndCreate([ + 'shape' => ['type' => 'square', 'code' => 'ab'], + 'shapes' => [['type' => 'square', 'code' => 'de']], + ]); + $this->fail('Expected the concrete morph rules to run.'); + } catch (ValidationException $exception) { + $this->assertEqualsCanonicalizing(['shape.code', 'shapes.0.code'], array_keys($exception->errors())); + } + } + + public function testValidationNormalizesCastOwnedObjectInput(): void + { + CastOwnedChildCast::$received = null; + + $model = CastOwnedParentData::validateAndCreate([ + 'child' => (new CastOwnedChildModel)->setRawAttributes(['external_name' => 'Taylor']), + ]); + + // The cast receives the validated input rather than a child created from the model. + $this->assertSame(['external_name' => 'Taylor'], CastOwnedChildCast::$received); + $this->assertSame('Taylor', $model->child->name); + + $nested = CastOwnedWrapperData::validateAndCreate([ + 'wrapper' => ['child' => (object) ['external_name' => 'Abigail']], + ]); + + $this->assertSame(['child' => ['external_name' => 'Abigail']], CastOwnedChildCast::$received); + $this->assertSame('Abigail', $nested->wrapper->child->name); + + $shapes = CastOwnedShapesData::validateAndCreate([ + 'shape' => (object) ['type' => 'square', 'code' => 'abc'], + 'shapes' => new Collection([(object) ['type' => 'square', 'code' => 'def']]), + ]); + + $this->assertInstanceOf(CastOwnedSquareData::class, $shapes->shape); + $this->assertSame('abc', $shapes->shape->code); + $this->assertInstanceOf(CastOwnedSquareData::class, $shapes->shapes[0]); + $this->assertSame('def', $shapes->shapes[0]->code); + + CountingNameNormalizer::$calls = 0; + $normalized = CastOwnedNormalizedParentData::validateAndCreate(['child' => 'Taylor']); + + // A string the child's normalizer reads is prepared like an array, and normalized once. + $this->assertSame('Taylor', $normalized->child->name); + $this->assertSame(1, CountingNameNormalizer::$calls); + + foreach ([ + // Under validation, input only the cast or a named factory could read fails the declared rules. + [CastOwnedParentData::class, ['child' => 'Taylor'], ['child']], + [CastOwnedParentData::class, ['child' => (object) ['other' => 'Taylor']], ['child.external_name']], + [CastOwnedWrapperData::class, ['wrapper' => ['child' => (object) ['other' => 'Abigail']]], ['wrapper.child.external_name']], + [CastOwnedShapesData::class, [ + 'shape' => (object) ['type' => 'square', 'code' => 'ab'], + 'shapes' => new Collection([(object) ['type' => 'square', 'code' => 'de']]), + ], ['shape.code', 'shapes.0.code']], + ] as [$class, $payload, $errors]) { + CastOwnedChildCast::$received = null; + + try { + $class::validateAndCreate($payload); + $this->fail("Expected [{$class}] to fail validation before its cast."); + } catch (ValidationException $exception) { + $this->assertEqualsCanonicalizing($errors, array_keys($exception->errors())); + $this->assertNull(CastOwnedChildCast::$received); + } + } + } + + public function testDeclinedCastsCreateFromThePreparedAndValidatedInput(): void + { + $outer = CastOwnedOuterData::validateAndCreate(['inner' => [ + 'child' => (object) ['external_name' => 'Taylor'], + 'address' => ['line_1' => '1 Main St', 'city' => 'Chicago'], + ]]); + + $this->assertInstanceOf(CastOwnedChildData::class, $outer->inner->child); + $this->assertSame('Taylor', $outer->inner->child->name); + $this->assertSame('1 Main St, Chicago', $outer->inner->address->address); + + $model = (new CastOwnedChildModel)->setRawAttributes(['name' => 'Taylor']); + $lazy = CastOwnedLazyParentData::validateAndCreate(['child' => $model]); + + $this->assertFalse($lazy->child->relation->shouldBeIncluded()); + + $model->setRelation('relation', ['external_name' => 'Abigail']); + + $this->assertSame('Abigail', $lazy->child->relation->resolve()->name); + } + + public function testNamedFactoriesBeneathACastRunOnlyAfterItDeclines(): void + { + PendingFactoryChildData::$calls = 0; + + $accepted = PendingFactoryAcceptedData::validateAndCreate([ + 'wrapper' => ['child' => new PendingFactorySource('Taylor')], + ]); + + $this->assertSame('cast', $accepted->wrapper->child->name); + $this->assertSame(0, PendingFactoryChildData::$calls); + + // A factory that could build the child does not exempt its input from validation. + try { + PendingFactoryAcceptedData::validateAndCreate(['wrapper' => ['child' => new PendingFactorySource('')]]); + $this->fail('Expected the child rules to run before the cast.'); + } catch (ValidationException $exception) { + $this->assertSame(['wrapper.child.name'], array_keys($exception->errors())); + } + + $declined = PendingFactoryDeclinedData::validateAndCreate(['wrapper' => [ + 'child' => new PendingFactorySource('Taylor'), + 'excluded' => new PendingFactorySource('Abigail'), + ]]); + + $this->assertSame('factory:Taylor', $declined->wrapper->child->name); + $this->assertNull($declined->wrapper->excluded); + $this->assertSame(1, PendingFactoryChildData::$calls); + + $hooked = PendingFactoryDeclinedData::factory() + ->alwaysValidate() + ->beforeValidation(static fn (array $payload): array => ['wrapper' => ['child' => ['name' => 'Hooked']]]) + ->from(['wrapper' => ['child' => new PendingFactorySource('Taylor')]]); + + // The hook replaced the input the factory matched, so the child is built from the new input. + $this->assertSame('Hooked', $hooked->wrapper->child->name); + $this->assertSame(1, PendingFactoryChildData::$calls); + } + + public function testDeclinedCastsKeepOrdinaryUnionAndIterableConversion(): void + { + $kept = CastOwnedUnionData::validateAndCreate(['child' => ['other' => 'kept'], 'numbers' => ['1']]); + $reads = 0; + $built = CastOwnedUnionData::from([ + 'child' => (object) ['external_name' => 'Taylor'], + 'numbers' => [], + 'children' => LazyCollection::make(function () use (&$reads): iterable { + ++$reads; + + yield 'first' => ['external_name' => 'Abigail']; + }), + ]); + + $this->assertSame(['other' => 'kept'], $kept->child); + $this->assertSame([1], $kept->numbers); + $this->assertSame('Taylor', $built->child->name); + $this->assertSame(0, $reads); + $this->assertSame('Abigail', $built->children->get('first')->name); + } + + public function testUnrelatedUnionArmPassesThroughAmbiguousDataCollectableTypes(): void + { + $data = AmbiguousDataCollectableCreationData::from(['children' => 'unchanged']); + + $this->assertSame('unchanged', $data->children); + } + + public function testPrepareForPipelineRunsForEachSource(): void + { + PreparedAddressData::$calls = 0; + + PreparedAddressData::from(['name' => 'Taylor'], ['line_1' => '1 Main St']); + + $this->assertSame(2, PreparedAddressData::$calls); + } + + public function testNestedObjectsArePreparedBeforeValidation(): void + { + $data = PreparedOwnerData::validateAndCreate([ + 'label' => 'home', + 'owner' => ['line_1' => '1 Main St', 'city' => 'Chicago'], + ]); + + $this->assertSame('1 Main St, Chicago', $data->owner->address); + } + + public function testModelSourcesArePreparedAsTheirDeclaredProperties(): void + { + $data = PreparedNameData::from((new PreparedNameModel)->forceFill(['name' => 'taylor'])); + + $this->assertSame('TAYLOR', $data->name); + } + + public function testTheMorphedClassPreparesItsPayload(): void + { + $shape = PreparedShapeData::from(['type' => 'circle', 'r' => '3']); + + $this->assertInstanceOf(PreparedCircleData::class, $shape); + $this->assertSame(3, $shape->radius); + + // prepareData hooks run first, so their output selects the class whose method then prepares it. + $hooked = PreparedShapeData::factory() + ->prepareData(static fn (array $input): array => [...$input, 'type' => 'circle', 'r' => '4']) + ->from(['type' => 'square']); + + $this->assertInstanceOf(PreparedCircleData::class, $hooked); + $this->assertSame(4, $hooked->radius); + } + + public function testClassNormalizersCustomCastsAndCreationHooksShareOneOperation(): void + { + $data = CustomizedCreationData::factory() + ->prepareData(fn (array $input): array => [ + ...$input, + 'label' => $input['label'] . '-prepared', + ]) + ->afterCreation(function (CustomizedCreationData $data): CustomizedCreationData { + $data->label = strtoupper($data->label); + + return $data; + }) + ->from(new CreationSource('item', 'identifier')); + + $this->assertSame(123, $data->id); + $this->assertSame('CAST:ITEM-PREPARED', $data->label); + } + + /** + * Test configured global normalizers participate in construction. + */ + #[DefineEnvironment('withConfiguredNormalizer')] + public function testConfiguredGlobalNormalizersParticipateInConstruction(): void + { + $data = ConfiguredNormalizerData::from(new CreationSource('item', 'identifier')); + + $this->assertSame('identifier', $data->id); + $this->assertSame('item', $data->label); + } + + public function testConstructorInputsReceiveRawInputByName(): void + { + $this->assertSame( + ['prefix' => 'later', 'options' => ['a' => '1']], + ConstructorInputCreationData::from(['prefix' => 'first', 'options' => ['a' => '1']], ['prefix' => 'later'])->received, + ); + + $hooked = ConstructorInputCreationData::factory() + ->beforeCreation(static fn (array $properties): array => [...$properties, 'prefix' => 'hooked']) + ->from([]); + + $this->assertSame(['prefix' => 'hooked', 'options' => []], $hooked->received); + + $this->expectException(CannotCreateData::class); + $this->expectExceptionMessageIsOrContains('Parameters missing: prefix'); + + ConstructorInputCreationData::from([]); + } + + public function testNonPublicPromotedParametersNeverReceiveInput(): void + { + $input = ['name' => 'Taylor', 'role' => 'admin', 'trusted' => true]; + + $this->assertSame( + ['role' => 'member', 'trusted' => false], + NonPublicPromotedCreationData::from($input)->state(), + ); + $this->assertSame( + ['role' => 'member', 'trusted' => false], + NonPublicPromotedCreationData::from(Request::create('/', 'POST', $input))->state(), + ); + + // A hook supplies a value deliberately. + $hooked = NonPublicPromotedCreationData::factory() + ->beforeCreation(static fn (array $properties): array => [...$properties, 'role' => 'editor']) + ->from($input); + + $this->assertSame(['role' => 'editor', 'trusted' => false], $hooked->state()); + + try { + StrictNonPublicPromotedCreationData::validateAndCreate(['name' => 'Taylor', 'role' => 'admin']); + $this->fail('Expected the non-public promoted key to be rejected as unknown.'); + } catch (ValidationException $exception) { + $this->assertSame(['role'], array_keys($exception->errors())); + } + } + + /** + * @param class-string $class + */ + #[DataProvider('requiredNonPublicPromotedClasses')] + public function testRequiredNonPublicPromotedParametersFailCreation(string $class): void + { + $this->expectException(CannotCreateData::class); + $this->expectExceptionMessageIsOrContains('Parameters missing: secret'); + + $class::from(['name' => 'Taylor', 'prefix' => 'p', 'secret' => 'input']); + } + + /** + * Get classes with a required non-public promoted parameter, with and without direct construction otherwise. + * + * @return array}> + */ + public static function requiredNonPublicPromotedClasses(): array + { + return [ + 'public promoted fields only' => [RequiredNonPublicPromotedCreationData::class], + 'with a constructor input' => [RequiredNonPublicPromotedWithInputCreationData::class], + ]; + } + + public function testBeforeCreationHooksReceiveOnlySuppliedUnboundProperties(): void + { + $received = null; + + $data = ConstructorAssignedCreationData::factory() + ->beforeCreation(function (array $properties) use (&$received): array { + $received = $properties; + + return [...$properties, 'nickname' => 'hooked']; + }) + ->from(['name' => 'Taylor']); + + $this->assertSame(['name' => 'Taylor'], $received); + $this->assertSame('hooked', $data->nickname); + $this->assertSame('label:Taylor', $data->label); + } + + public function testRejectsSuppliedComputedValuesAndInvalidAfterCreationResults(): void + { + try { + ComputedCreationData::from(['id' => 1, 'summary' => 'client']); + $this->fail('Expected computed input to be rejected.'); + } catch (CannotSetComputedValue $exception) { + $this->assertStringContainsString('ComputedCreationData::$summary', $exception->getMessage()); + } + + $this->expectException(CannotCreateData::class); + $this->expectExceptionMessageIsOrContains('instead of an instance of'); + + BasicCreationData::factory() + ->afterCreation(fn (): ChildCreationData => new ChildCreationData(1)) + ->from(['name' => 'Taylor']); + } + + #[DefineEnvironment('withIgnoredComputedInput')] + public function testSuppliedComputedAndVirtualValuesCanBeIgnored(): void + { + $payload = ['id' => 1, 'computed' => 'client', 'virtual' => 'client']; + + $created = [ + DirectOutputOnlyCreationData::from($payload), + DirectOutputOnlyCreationData::validateAndCreate($payload), + DirectOutputOnlyCreationData::factory() + ->alwaysValidate() + ->beforeValidation(static fn (array $payload): array => [...$payload, 'computed' => 'hook']) + ->from(['id' => 1]), + ]; + + foreach ($created as $data) { + $this->assertSame(1, $data->id); + $this->assertSame('computed', $data->computed); + $this->assertSame('virtual', $data->virtual); + } + } + + /** + * Capture a creation value or its exact failure contract. + * + * @return array{result: 'exception', class: class-string, message: string}|array{result: 'value', value: mixed} + */ + protected function captureCreationOutcome(Closure $create): array + { + try { + return ['result' => 'value', 'value' => $create()]; + } catch (Throwable $exception) { + return [ + 'result' => 'exception', + 'class' => $exception::class, + 'message' => $exception->getMessage(), + ]; + } + } + + /** + * Configure a global data normalizer. + */ + protected function withConfiguredNormalizer(Application $app): void + { + $app->make('config')->set('data.normalizers', [CreationSourceNormalizer::class]); + } + + /** + * Ignore supplied computed property values. + */ + protected function withIgnoredComputedInput(Application $app): void + { + $app->make('config')->set('data.features.ignore_exception_when_trying_to_set_computed_property_value', true); + } + + /** + * Configure a global string cast. + */ + protected function withStringToUpperCast(Application $app): void + { + $app->make('config')->set('data.casts', ['string' => StringToUpperCast::class]); + } +} + +class BasicCreationData extends Data +{ + public function __construct( + #[MapInputName('profile.name')] + public string $name, + public ?string $nickname, + public string|Optional $note, + public int $age = 18, + ) { + } +} + +class OptionalDefaultCreationData extends Data +{ + /** + * Create an optional default fixture. + */ + public function __construct( + public string|Optional $note = 'default', + ) { + } +} + +class PrepareDataIdentityData extends Data +{ + /** + * Create a prepare-data identity fixture. + * + * @param array $meta + */ + public function __construct( + public string $name, + public array $meta = [], + ) { + } +} + +class MappedPrepareDataIdentityData extends Data +{ + /** + * Create a mapped prepare-data identity fixture. + */ + public function __construct( + #[MapInputName('profile.name')] + public string $name, + #[MapInputName('user_code')] + public string $code, + ) { + } +} + +class FactoryOverrideCreationData extends Data +{ + public static int $factoryCalls = 0; + + public function __construct( + #[MapInputName('profile.name')] + public string $name, + ) { + } + + /** + * Create a factory without property name mapping. + */ + public static function factory(?CreationContext $creationContext = null): CreationContextFactory + { + ++self::$factoryCalls; + + return parent::factory($creationContext)->withoutPropertyNameMapping(); + } +} + +class DirectArrayCreationData extends Data +{ + public string $assigned; + + public string $unboundDefault = 'unbound-default'; + + #[Computed] + public string $computed = 'computed'; + + public string $virtual { + get => 'virtual'; + } + + public function __construct( + #[MapInputName('profile.name')] + public string $name, + #[MapInputName('nullable_value')] + public ?string $nullable, + public string|Optional $optional, + public ChildCreationData $child, + public DateTimeImmutable $date, + public CreationStatus $status, + public CreationSource $source, + public ?string $defaultedNullable = 'fallback', + public int $defaultedInteger = 21, + public array $metadata = [], + ) { + } +} + +class DirectNestedCreationData extends Data +{ + public function __construct(public ChildCreationData $child) + { + } +} + +class DirectConvertedCreationData extends Data +{ + public function __construct( + public int $id, + public DateTimeImmutable $date, + public CreationStatus $status, + public IntegerCreationStatus $integerStatus, + ) { + } +} + +class PreflightChildCreationData extends Data +{ + public static int $constructorCalls = 0; + + public function __construct(public int $id) + { + ++self::$constructorCalls; + } +} + +class PreflightParentCreationData extends Data +{ + public function __construct( + public PreflightChildCreationData $child, + public CreationSource $source, + ) { + } +} + +class GeneralChildCreationData extends Data +{ + public function __construct( + #[WithCast(DeferredItemCreationCast::class)] + public int $id, + ) { + } +} + +class LeanParentWithGeneralChildrenData extends Data +{ + public function __construct( + public GeneralChildCreationData $first, + public GeneralChildCreationData $second, + ) { + } +} + +class DirectOutputOnlyCreationData extends Data +{ + #[Computed] + public string $computed = 'computed'; + + public string $virtual { + get => 'virtual'; + } + + public function __construct(public int $id) + { + } +} + +class DirectValidationModeCreationData extends Data +{ + public function __construct(public int $id) + { + } +} + +class DirectPrivateConstructorCreationData extends Data +{ + public readonly string $value; + + private function __construct(string $value) + { + $this->value = $value; + } +} + +class DirectVariadicConstructorCreationData extends Data +{ + public string $name; + + public array $items; + + public function __construct(string $name, mixed ...$items) + { + $this->name = $name; + $this->items = $items; + } +} + +class DirectPrivateVariadicConstructorCreationData extends Data +{ + public readonly array $items; + + private function __construct(mixed ...$items) + { + $this->items = $items; + } + + public static function fromPayload(array $payload): self + { + return new self(...$payload['items']); + } +} + +class ChildCreationData extends Data +{ + public function __construct( + public int $id, + ) { + } +} + +class ChildCreationDataSubtype extends ChildCreationData +{ +} + +class UnrelatedChildCreationData extends Data +{ + public function __construct( + public string $id, + ) { + } +} + +class ParentCreationData extends Data +{ + public function __construct( + public ChildCreationData $child, + ) { + } +} + +class IterableCreationData extends Data +{ + /** + * Create an iterable fixture. + * + * @param array $children + * @param Collection $collection + */ + public function __construct( + #[DataCollectionOf(ChildCreationData::class)] + public array $children, + #[DataCollectionOf(ChildCreationData::class)] + public Collection $collection, + ) { + } +} + +class LazyIterableCreationData extends Data +{ + /** + * Create a lazy iterable fixture. + * + * @param LazyCollection $children + */ + public function __construct( + #[DataCollectionOf(ChildCreationData::class)] + public LazyCollection $children, + ) { + } +} + +class LazyRequestIterableCreationData extends Data +{ + /** + * Create a lazy Request iterable fixture. + * + * @param LazyCollection $children + */ + public function __construct( + #[DataCollectionOf(LazyRequestItemCreationData::class)] + public LazyCollection $children, + ) { + } +} + +class LazyRequestItemCreationData extends Data +{ + public static int $authorizationCalls = 0; + + public function __construct( + public int $id, + ) { + } + + public static function authorize(): bool + { + ++self::$authorizationCalls; + + return false; + } +} + +class LazyCastIterableCreationData extends Data +{ + /** + * Create a lazy cast iterable fixture. + * + * @param LazyCollection $children + */ + public function __construct( + #[DataCollectionOf(LazyCastItemCreationData::class)] + public LazyCollection $children, + ) { + } +} + +class LazyCastItemCreationData extends Data +{ + public function __construct( + #[WithCast(DeferredItemCreationCast::class)] + public int $id, + ) { + } +} + +class DeferredItemCreationCast implements Cast +{ + public static int $instances = 0; + + public function __construct() + { + ++self::$instances; + } + + public function cast( + DataProperty $property, + mixed $value, + array $properties, + CreationContext $context, + ): int { + return (int) $value; + } +} + +class AutoLazyCreationData extends Data +{ + /** + * Create an automatic-lazy fixture. + * + * @param Lazy|list $tags + * @param Lazy|Paginator $children + */ + public function __construct( + #[RecordingAutoLazy] + public Lazy|string $title, + #[RecordingAutoLazy] + public Lazy|array $tags, + #[RecordingAutoLazy] + public Lazy|ChildCreationData $child, + #[RecordingAutoLazy, DataCollectionOf(ChildCreationData::class)] + public Lazy|Paginator $children, + ) { + } +} + +class CountingAutoLazyChildData extends Data +{ + public static int $constructorCalls = 0; + + public function __construct(public int $id) + { + ++self::$constructorCalls; + } +} + +class CountingAutoLazyParentData extends Data +{ + public function __construct( + #[AutoLazy] + public Lazy|CountingAutoLazyChildData $child, + ) { + } +} + +class MappedAutoLazyParentData extends Data +{ + public function __construct( + #[AutoLazy, MapInputName('profile.child')] + public Lazy|CountingAutoLazyChildData $child, + ) { + } +} + +class CountingAutoLazyCollectionData extends Data +{ + /** + * Create a counting automatic-lazy collection fixture. + * + * @param Lazy|Paginator $children + */ + public function __construct( + #[AutoLazy, DataCollectionOf(CountingAutoLazyChildData::class)] + public Lazy|Paginator $children, + ) { + } +} + +class CountingAutoLazyFactoryChildData extends Data +{ + public static int $factoryCalls = 0; + + public function __construct(public int $id) + { + } + + public static function fromArray(array $payload): self + { + ++self::$factoryCalls; + + return new self((int) $payload['id']); + } +} + +class CountingAutoLazyFactoryCollectionData extends Data +{ + /** + * Create a counting automatic-lazy factory collection fixture. + * + * @param Lazy|Paginator $children + */ + public function __construct( + #[AutoLazy, DataCollectionOf(CountingAutoLazyFactoryChildData::class)] + public Lazy|Paginator $children, + ) { + } +} + +class AutoLazyFirstSource +{ + public function __construct( + public readonly string $title, + ) { + } +} + +class AutoLazySecondSource +{ + /** + * Create an automatic-lazy source fixture. + * + * @param list $tags + * @param array{id: string} $child + */ + public function __construct( + public readonly array $tags, + public readonly array $child, + ) { + } +} + +class AutoLazyNamedPayloadData extends Data +{ + public function __construct( + #[RecordingAutoLazy] + public Lazy|string $title, + ) { + } +} + +#[Attribute(Attribute::TARGET_PROPERTY)] +class RecordingAutoLazy extends AutoLazy +{ + /** @var array */ + public static array $payloads = []; + + /** @var array */ + public static array $replays = []; + + /** + * Build an inspectable automatic lazy value. + */ + public function build( + Closure $castValue, + mixed $payload, + DataProperty $property, + mixed $value, + ): Lazy { + $variables = (new ReflectionFunction($castValue))->getStaticVariables(); + self::$payloads[$property->name] = $payload; + self::$replays[$property->name] = $variables['replay'] ?? null; + + return parent::build($castValue, $payload, $property, $value); + } + + /** + * Reset captured automatic lazy state. + */ + public static function reset(): void + { + self::$payloads = []; + self::$replays = []; + } +} + +class AutoLazyNormalizedParentData extends Data +{ + public function __construct( + #[AutoLazy] + public Lazy|AutoLazyNormalizedChildData $child, + ) { + } +} + +class AutoLazyOuterSource +{ + /** + * Create an automatic-lazy outer source fixture. + * + * @param array{id: string} $child + */ + public function __construct( + public readonly array $child, + ) { + } +} + +class AutoLazyPaginatorCreationData extends Data { - public static int $factoryCalls = 0; + /** + * Create an automatic-lazy paginator fixture. + * + * @param Lazy|Paginator $children + */ + public function __construct( + #[AutoLazy, DataCollectionOf(ChildCreationData::class)] + public Lazy|Paginator $children, + ) { + } +} +class AutoLazyNormalizedChildData extends Data +{ public function __construct( - #[MapInputName('profile.name')] - public string $name, + public int $id, + ) { + } + + public static function normalizers(): array + { + return [AutoLazyCountingNormalizer::class]; + } +} + +class AutoLazyCountingNormalizer implements Normalizer +{ + public static int $calls = 0; + + public function normalize(mixed $value): array|Normalized|null + { + ++self::$calls; + + return null; + } +} + +class AutoWhenLoadedCreationData extends Data +{ + public function __construct( + #[AutoWhenLoadedLazy] + public Lazy|AutoLazyNormalizedChildData|null $child, + ) { + } +} + +class RecipeAutoWhenLoadedCreationData extends Data +{ + public function __construct( + #[AutoWhenLoadedLazy] + public Lazy|CountingAutoLazyChildData|null $child, + ) { + } +} + +class NonReplayAutoLazyCreationData extends Data +{ + public function __construct( + #[ResolvedValueAutoLazy] + public Lazy|string $value, + ) { + } +} + +#[Attribute(Attribute::TARGET_PROPERTY)] +class ResolvedValueAutoLazy extends AutoLazy +{ + /** + * Build an automatic lazy value from a distinct resolved input. + */ + public function build( + Closure $castValue, + mixed $payload, + DataProperty $property, + mixed $value, + ): Lazy { + return Lazy::create(static fn () => $castValue('resolved')); + } +} + +class AutoLazyRelationModel extends Model +{ +} + +class AutoLazyMorphParentCreationData extends Data +{ + public function __construct( + #[AutoLazy] + public Lazy|AutoLazyMorphCreationData $child, + ) { + } +} + +abstract class AutoLazyMorphCreationData extends Data implements PropertyMorphableData +{ + public function __construct( + #[PropertyForMorph] + public string $type, + ) { + } + + public static function morph(array $properties): ?string + { + return match ($properties['type']) { + 'plain' => AutoLazyPlainMorphCreationData::class, + 'relation' => AutoLazyRelationMorphCreationData::class, + default => null, + }; + } +} + +class AutoLazyPlainMorphCreationData extends AutoLazyMorphCreationData +{ +} + +class AutoLazyRelationMorphCreationData extends AutoLazyMorphCreationData +{ + public function __construct( + string $type, + #[AutoWhenLoadedLazy] + public Lazy|AutoLazyNormalizedChildData|null $child = null, + ) { + parent::__construct($type); + } +} + +class AutoLazyDefaultCreationData extends Data +{ + public function __construct( + #[RecordingAutoLazy] + public Lazy|string $title = 'default', + #[RecordingAutoLazy] + public Lazy|ChildCreationData $child = new ChildCreationData(12), + #[RecordingAutoLazy] + public Lazy|string|null $nullable = null, + #[RecordingAutoLazy] + public Lazy|string|Optional $optional = new Optional, + ) { + } +} + +class AutoLazyVariantsCreationData extends Data +{ + public function __construct( + #[AutoClosureLazy] + public Lazy|ChildCreationData $closure, + #[AutoInertiaLazy] + public Lazy|ChildCreationData $inertia, + #[AutoInertiaDeferred('analytics', rescue: true)] + public Lazy|ChildCreationData $deferred, + ) { + } +} + +class FinishedCollectionCreationData extends Data +{ + /** + * Create a finished-collection fixture. + * + * @param Collection $collection + */ + public function __construct( + #[DataCollectionOf(ChildCreationData::class)] + public DataCollection $dataCollection, + #[DataCollectionOf(ChildCreationData::class)] + public Collection $collection, + ) { + } +} + +class DataCollectionCreationData extends Data +{ + /** + * Create a data-collection construction fixture. + * + * @param DataCollection $children + */ + public function __construct( + #[DataCollectionOf(ChildCreationData::class)] + public DataCollection $children, ) { } +} + +class DataPaginatorCreationData extends Data +{ + /** + * Create a paginated data fixture. + * + * @param Paginator $children + */ + public function __construct( + #[DataCollectionOf(ChildCreationData::class)] + public Paginator $children, + ) { + } +} + +class ScalarPaginatorCreationData extends Data +{ + /** + * Create a scalar paginator fixture. + * + * @param Paginator $ids + */ + public function __construct(public Paginator $ids) + { + } +} + +class ScalarIterableCreationData extends Data +{ + /** @var list */ + public array $ids; - public static function factory(): CreationContextFactory + /** @var list */ + public array $statuses; + + /** @var list */ + public array $dates; + + public function __construct(array $ids, array $statuses, array $dates) + { + $this->ids = $ids; + $this->statuses = $statuses; + $this->dates = $dates; + } +} + +class IntegerRangeCreationData extends Data +{ + /** + * Create an integer-range fixture. + * + * @param int<0, max> $value + */ + public function __construct(public int $value) + { + } +} + +class NonEmptyArrayCreationData extends Data +{ + /** + * Create a non-empty array fixture. + * + * @param non-empty-array $children + */ + public function __construct(public array $children) { - ++self::$factoryCalls; - - return parent::factory()->withoutPropertyNameMapping(); } } -class DirectArrayCreationData extends Data +enum CreationStatus: string { - public string $assigned; - - public string $unboundDefault = 'unbound-default'; - - #[Computed] - public string $computed = 'computed'; + case Active = 'active'; + case Inactive = 'inactive'; +} - public string $virtual { - get => 'virtual'; - } +enum IntegerCreationStatus: int +{ + case Active = 1; +} +class CreationDto extends Dto +{ public function __construct( - #[MapInputName('profile.name')] - public string $name, - #[MapInputName('nullable_value')] - public ?string $nullable, - public string|Optional $optional, - public ChildCreationData $child, - public DateTimeImmutable $date, - public CreationStatus $status, - public CreationSource $source, - public ?string $defaultedNullable = 'fallback', - public int $defaultedInteger = 21, - public array $metadata = [], + public int $id, ) { } } -class DirectNestedCreationData extends Data +class CreationResource extends Resource { - public function __construct(public ChildCreationData $child) - { + public function __construct( + public int $id, + ) { } } -class DirectConvertedCreationData extends Data +class NamedFactoryCreationData extends Data { public function __construct( - public int $id, - public DateTimeImmutable $date, - public CreationStatus $status, - public IntegerCreationStatus $integerStatus, + public string $value, ) { } -} -class PreflightChildCreationData extends Data -{ - public static int $constructorCalls = 0; + public static function fromString(string $value): self + { + return new self('direct:' . $value); + } - public function __construct(public int $id) + public static function fromNumber(int $value): array { - ++self::$constructorCalls; + return ['value' => 'number:' . $value]; } } -class PreflightParentCreationData extends Data +class InjectedFactoryCreationData extends Data { public function __construct( - public PreflightChildCreationData $child, - public CreationSource $source, + public string $value, ) { } + + public static function fromInjected( + string $value, + NamedFactoryCreationDependency $dependency, + CreationContext $context, + ): self { + return new self($value . ':' . $dependency->value . ':' . $context->dataClass); + } } -class GeneralChildCreationData extends Data +class NamedFactoryCreationDependency +{ + public string $value = 'dependency'; +} + +class ContextualConversionCreationData extends Data { + /** + * Create a fixture whose values come from route parameters. + * + * @param array $items + */ public function __construct( - #[WithCast(DeferredItemCreationCast::class)] + #[RouteParameter('id')] public int $id, + #[RouteParameter('status')] + public DummyBackedEnum $status, + #[RouteParameter('at')] + public DateTimeImmutable $at, + #[RouteParameter('child')] + public SimpleData $child, + #[RouteParameter('choice')] + public SimpleData|string $choice, + #[RouteParameter('items')] + public array $items, + #[RouteParameter('missing')] + public ?SimpleData $missing, ) { } } -class LeanParentWithGeneralChildrenData extends Data +interface ContextualCreationContract { - public function __construct( - public GeneralChildCreationData $first, - public GeneralChildCreationData $second, - ) { - } } -class DirectOutputOnlyCreationData extends Data +class ContextualCreationDefault implements ContextualCreationContract { - #[Computed] - public string $computed = 'computed'; +} - public string $virtual { - get => 'virtual'; - } +class ContextualCreationForData implements ContextualCreationContract +{ +} - public function __construct(public int $id) +#[Attribute(Attribute::TARGET_PARAMETER)] +class ContextualCreationDependency implements ContextualAttribute +{ + /** + * Resolve the class the container gives for the contract. + */ + public static function resolve(self $attribute, Container $container): string { + return $container->make(ContextualCreationContract::class)::class; } } -class DirectNamedFactoryCreationData extends Data +#[Attribute(Attribute::TARGET_CLASS)] +class ContextualCreationMarker { - public static int $calls = 0; +} - public function __construct(public int $id) +#[Attribute(Attribute::TARGET_PARAMETER)] +class ValidatedContextualValue implements ContextualAttribute +{ + /** @var array */ + public static array $resolutions = []; + + /** @var array */ + public static array $values = []; + + /** + * Create the attribute for one named server value. + */ + public function __construct(public string $name) { } - public static function fromString(string $value): array + /** + * Count the resolution and return the configured value. + */ + public static function resolve(self $attribute, Container $container): mixed { - ++self::$calls; + static::$resolutions[$attribute->name] = (static::$resolutions[$attribute->name] ?? 0) + 1; - return ['id' => (int) $value]; + return static::$values[$attribute->name] ?? null; } } -class DirectValidationModeCreationData extends Data +class ValidatedContextualData extends Data { - public function __construct(public int $id) - { + /** + * Create a fixture whose contextual values carry and satisfy rules. + */ + public function __construct( + #[ValidatedContextualValue('id'), Max(10)] + public int $id, + #[RequiredIf('id', 5)] + public ?string $note, + #[MapInputName('owner_id'), ValidatedContextualValue('owner')] + public int $ownerId, + public ?ValidatedContextualChildData $child = null, + ) { } } -class DirectPrivateConstructorCreationData extends Data +class ValidatedContextualChildData extends Data { - public readonly string $value; - - private function __construct(string $value) - { - $this->value = $value; + /** + * Create a nested fixture with a validated contextual value. + */ + public function __construct( + #[ValidatedContextualValue('child'), Max(10)] + public int $value, + public string $name, + ) { } } -class DirectVariadicConstructorCreationData extends Data +class ExcludedContextualData extends Data { - public string $name; + #[Computed] + public string $serviceLabel; - public array $items; + /** + * Create a fixture whose contextual values validation may exclude. + */ + public function __construct( + public bool $skip, + #[ValidatedContextualValue('nullable'), ExcludeIf('skip', true)] + public ?string $nullable, + #[ValidatedContextualValue('service')] + string $service, + #[ValidatedContextualValue('defaulted'), ExcludeIf('skip', true)] + public string $defaulted = 'default', + ) { + $this->serviceLabel = $service; + } +} - public function __construct(string $name, mixed ...$items) - { - $this->name = $name; - $this->items = $items; +class RequiredExcludedContextualData extends Data +{ + /** + * Create a fixture whose excluded contextual value has no missing value. + */ + public function __construct( + public bool $skip, + #[ValidatedContextualValue('required'), ExcludeIf('skip', true)] + public string $required, + ) { } } -class DirectPrivateVariadicConstructorCreationData extends Data +abstract class ContextualMorphData extends Data implements PropertyMorphableData { - public readonly array $items; + /** + * Create a morphable fixture whose subclasses resolve different contextual values. + */ + public function __construct( + #[PropertyForMorph] + public string $type, + ) { + } - private function __construct(mixed ...$items) + /** + * Get the subclass for the given type. + */ + public static function morph(array $properties): ?string { - $this->items = $items; + return match ($properties['type']) { + 'first' => FirstContextualMorphData::class, + 'second' => SecondContextualMorphData::class, + }; } +} - public static function fromPayload(array $payload): self - { - return new self(...$payload['items']); +class FirstContextualMorphData extends ContextualMorphData +{ + /** + * Create the first morph with its contextual value. + */ + public function __construct( + string $type, + #[ValidatedContextualValue('first')] + public int $value, + ) { + parent::__construct($type); } } -class ChildCreationData extends Data +class SecondContextualMorphData extends ContextualMorphData { + /** + * Create the second morph with a validated contextual value. + */ public function __construct( - public int $id, + string $type, + #[ValidatedContextualValue('second'), Max(10)] + public int $value, ) { + parent::__construct($type); } } -class ChildCreationDataSubtype extends ChildCreationData +class ContextualMorphListData extends Data { + /** + * Create a list of contextual morph items. + * + * @param array $items + */ + public function __construct( + #[DataCollectionOf(ContextualMorphData::class)] + public array $items, + ) { + } } -class UnrelatedChildCreationData extends Data +#[ContextualCreationMarker] +class ContextualResolutionCreationData extends Data { + public string $label; + + /** + * Create a fixture with promoted and constructor-only contextual values. + */ public function __construct( - public string $id, + #[ContextualCreationDependency] + public string $dependency, + #[Config('app.name')] + string $appName, ) { + $this->label = $appName; } } -class ParentCreationData extends Data +abstract class ShapeCreationData extends Data implements PropertyMorphableData { public function __construct( - public ChildCreationData $child, + #[PropertyForMorph] + public string $type, ) { } + + public static function morph(array $properties): ?string + { + return match ($properties['type']) { + 'invalid' => ChildCreationData::class, + default => null, + }; + } } -class IterableCreationData extends Data +abstract class IntegerShapeCreationData extends Data implements PropertyMorphableData { - /** - * Create an iterable fixture. - * - * @param array $children - * @param Collection $collection - */ public function __construct( - #[DataCollectionOf(ChildCreationData::class)] - public array $children, - #[DataCollectionOf(ChildCreationData::class)] - public Collection $collection, + #[PropertyForMorph] + public IntegerCreationStatus $status, ) { } + + public static function morph(array $properties): ?string + { + return match ($properties['status']) { + IntegerCreationStatus::Active => IntegerCircleCreationData::class, + }; + } } -class LazyIterableCreationData extends Data +class IntegerCircleCreationData extends IntegerShapeCreationData +{ + public function __construct(IntegerCreationStatus $status, public int $radius) + { + parent::__construct($status); + } +} + +class MappedItemCreationData extends Data { - /** - * Create a lazy iterable fixture. - * - * @param LazyCollection $children - */ public function __construct( - #[DataCollectionOf(ChildCreationData::class)] - public LazyCollection $children, + #[MapInputName('profile.name')] + public string $name, ) { } } -class LazyRequestIterableCreationData extends Data +class MappedItemListCreationData extends Data { /** - * Create a lazy Request iterable fixture. + * Create a mapped-item list fixture. * - * @param LazyCollection $children + * @param array $items */ public function __construct( - #[DataCollectionOf(LazyRequestItemCreationData::class)] - public LazyCollection $children, + #[DataCollectionOf(MappedItemCreationData::class)] + public array $items, ) { } } -class LazyRequestItemCreationData extends Data +class AlternateChildCreationData extends Data { - public static int $authorizationCalls = 0; - public function __construct( public int $id, ) { } +} - public static function authorize(): bool - { - ++self::$authorizationCalls; - - return false; +class AmbiguousCreationData extends Data +{ + public function __construct( + public ChildCreationData|AlternateChildCreationData $child, + ) { } } -class LazyCastIterableCreationData extends Data +class MappedUnionChildCreationData extends Data { /** - * Create a lazy cast iterable fixture. - * - * @param LazyCollection $children + * Create a child whose input name is mapped. */ public function __construct( - #[DataCollectionOf(LazyCastItemCreationData::class)] - public LazyCollection $children, + #[MapInputName('external_name')] + public string $name, ) { } } -class LazyCastItemCreationData extends Data +class DataObjectUnionCreationData extends Data { + /** + * Create a fixture whose unions keep accepted arrays and build a child from other values. + */ public function __construct( - #[WithCast(DeferredItemCreationCast::class)] - public int $id, + public array|MappedUnionChildCreationData $child, + public Collection|array|MappedUnionChildCreationData|null $containers = null, ) { } -} - -class DeferredItemCreationCast implements Cast -{ - public static int $instances = 0; - public function __construct() + /** + * Require the child name, which a built child reads from its mapped input name. + */ + public static function rules(): array { - ++self::$instances; - } - - public function cast( - DataProperty $property, - mixed $value, - ConstructionState $state, - CreationContext $context, - ): int { - return (int) $value; + return ['child.name' => ['required']]; } } -class AutoLazyCreationData extends Data +class DataObjectUnionListData extends Data { /** - * Create an automatic-lazy fixture. + * Create a fixture whose items each hold a data object union. * - * @param Lazy|list $tags - * @param Lazy|Paginator $children + * @param array $items */ public function __construct( - #[RecordingAutoLazy] - public Lazy|string $title, - #[RecordingAutoLazy] - public Lazy|array $tags, - #[RecordingAutoLazy] - public Lazy|ChildCreationData $child, - #[RecordingAutoLazy, DataCollectionOf(ChildCreationData::class)] - public Lazy|Paginator $children, + #[DataCollectionOf(DataObjectUnionCreationData::class)] + public array $items, ) { } } -class CountingAutoLazyChildData extends Data +class AmbiguousDataBeforeDateCreationData extends Data { - public static int $constructorCalls = 0; - - public function __construct(public int $id) - { - ++self::$constructorCalls; + public function __construct( + public ChildCreationData|AlternateChildCreationData|DateTimeImmutable $value, + ) { } } -class CountingAutoLazyParentData extends Data +class CastableBeforeDateCreationData extends Data { public function __construct( - #[AutoLazy] - public Lazy|CountingAutoLazyChildData $child, + public PriorityCreationCastable|DateTimeImmutable $value, ) { } } -class MappedAutoLazyParentData extends Data +class AmbiguousDateBeforeEnumCreationData extends Data { public function __construct( - #[AutoLazy, MapInputName('profile.child')] - public Lazy|CountingAutoLazyChildData $child, + public DateTimeImmutable|DateTime|PriorityCreationStatus $value, ) { } } -class CountingAutoLazyCollectionData extends Data +class AmbiguousEnumBeforeBuiltinCreationData extends Data { - /** - * Create a counting automatic-lazy collection fixture. - * - * @param Lazy|Paginator $children - */ public function __construct( - #[AutoLazy, DataCollectionOf(CountingAutoLazyChildData::class)] - public Lazy|Paginator $children, + public PriorityCreationStatus|AlternatePriorityCreationStatus|int $value, ) { } } -class CountingAutoLazyFactoryChildData extends Data +class PriorityCreationCastable implements Castable { - public static int $factoryCalls = 0; - - public function __construct(public int $id) - { + public function __construct( + public readonly string $value, + ) { } - public static function fromArray(array $payload): self + /** + * Create the cast for this type. + */ + public static function dataCastUsing(array $arguments): Cast { - ++self::$factoryCalls; - - return new self((int) $payload['id']); + return new PriorityCreationCast; } } -class CountingAutoLazyFactoryCollectionData extends Data +class PriorityCreationCast implements Cast { /** - * Create a counting automatic-lazy factory collection fixture. - * - * @param Lazy|Paginator $children + * Cast a value into the declared Castable type. */ - public function __construct( - #[AutoLazy, DataCollectionOf(CountingAutoLazyFactoryChildData::class)] - public Lazy|Paginator $children, - ) { + public function cast( + DataProperty $property, + mixed $value, + array $properties, + CreationContext $context, + ): PriorityCreationCastable { + return new PriorityCreationCastable((string) $value); } } -class AutoLazyFirstSource +enum PriorityCreationStatus: string { - public function __construct( - public readonly string $title, - ) { - } + case Active = 'active'; } -class AutoLazySecondSource +enum AlternatePriorityCreationStatus: string +{ + case Inactive = 'inactive'; +} + +class AmbiguousDataCollectableCreationData extends Data { /** - * Create an automatic-lazy source fixture. + * Create an ambiguous data-collectable fixture. * - * @param list $tags - * @param array{id: string} $child + * @param Collection|DataCollection|string $children */ public function __construct( - public readonly array $tags, - public readonly array $child, + public Collection|DataCollection|string $children, ) { } } -class AutoLazyNamedFactoryData extends Data +class CastedAmbiguousDataCollectableCreationData extends Data { - public static ?AutoLazyNamedFactorySource $source = null; - + /** + * Create a cast-owned ambiguous data-collectable fixture. + * + * @param Collection|DataCollection $children + */ public function __construct( - #[RecordingAutoLazy] - public Lazy|string $title, + #[WithCast(AmbiguousDataCollectableCreationCast::class)] + public Collection|DataCollection $children, ) { } - - public static function fromString(string $title): AutoLazyNamedFactorySource - { - return self::$source = new AutoLazyNamedFactorySource($title); - } } -class AutoLazyNamedPayloadData extends Data +class TypedContainerUnionCreationData extends Data { + /** + * Create a fixture whose container unions declare their item types. + * + * @param array|Collection $both + * @param int[] $shorthand + * @param array $arrayOnly + */ public function __construct( - #[RecordingAutoLazy] - public Lazy|string $title, + public Collection|array $both, + public Collection|array $shorthand, + public Collection|array $arrayOnly, ) { } } -class AutoLazyNamedFactorySource +class OverlappingContainerUnionCreationData extends Data { + /** + * Create a fixture whose container types both accept a collection. + * + * @param Collection|Enumerable $values + */ public function __construct( - public readonly string $title, + public Collection|Enumerable $values, ) { } } -#[Attribute(Attribute::TARGET_PROPERTY)] -class RecordingAutoLazy extends AutoLazy +class MixedContainerUnionCreationData extends Data { - /** @var array */ - public static array $payloads = []; - - /** @var array */ - public static array $replays = []; - - /** - * Build an inspectable automatic lazy value. - */ - public function build( - Closure $castValue, - mixed $payload, - DataProperty $property, - mixed $value, - ): Lazy { - $variables = (new ReflectionFunction($castValue))->getStaticVariables(); - self::$payloads[$property->name] = $payload; - self::$replays[$property->name] = $variables['replay'] ?? null; - - return parent::build($castValue, $payload, $property, $value); - } - /** - * Reset captured automatic lazy state. + * Create a fixture whose union holds a data collection and a plain array. + * + * @param array|Collection $children */ - public static function reset(): void - { - self::$payloads = []; - self::$replays = []; - } -} - -class AutoLazyNormalizedParentData extends Data -{ public function __construct( - #[AutoLazy] - public Lazy|AutoLazyNormalizedChildData $child, + public Collection|array $children, ) { } } -class AutoLazyOuterSource +class MixedContainerUnionListData extends Data { /** - * Create an automatic-lazy outer source fixture. + * Create a fixture whose items each hold a container union. * - * @param array{id: string} $child + * @param array $items */ public function __construct( - public readonly array $child, + #[DataCollectionOf(MixedContainerUnionCreationData::class)] + public array $items, ) { } } -class AutoLazyPaginatorCreationData extends Data +class AutoLazyContainerUnionCreationData extends Data { /** - * Create an automatic-lazy paginator fixture. + * Create a fixture whose automatic lazy container union is resolved later. * - * @param Lazy|Paginator $children + * @param array|Collection|Lazy $children */ public function __construct( - #[AutoLazy, DataCollectionOf(ChildCreationData::class)] - public Lazy|Paginator $children, + #[AutoLazy] + public Lazy|Collection|array $children, ) { } } -class AutoLazyNormalizedChildData extends Data +class ConfiguredItemCastUnionCreationData extends Data { + /** + * Create a fixture whose container union holds strings. + * + * @param array|Collection $names + */ public function __construct( - public int $id, + public Collection|array $names, ) { } +} - public static function normalizers(): array - { - return [AutoLazyCountingNormalizer::class]; +class AmbiguousDataCollectableCreationCast implements Cast +{ + public function cast( + DataProperty $property, + mixed $value, + array $properties, + CreationContext $context, + ): Collection { + return new Collection([new ChildCreationData((int) $value)]); } } -class AutoLazyCountingNormalizer implements Normalizer +class CreationSource { - public static int $calls = 0; - - public function normalize(mixed $value): array|Normalized|null - { - ++self::$calls; - - return null; + public function __construct( + public readonly string $label, + public readonly string $identifier, + ) { } } -class AutoWhenLoadedCreationData extends Data +class CustomizedCreationData extends Data { public function __construct( - #[AutoWhenLoadedLazy] - public Lazy|AutoLazyNormalizedChildData|null $child, + #[WithCast(CreationIdentifierCast::class)] + public int $id, + #[WithCast(CreationLabelCast::class)] + public string $label, ) { } + + public static function normalizers(): array + { + return [CreationSourceNormalizer::class]; + } } -class RecipeAutoWhenLoadedCreationData extends Data +class ConfiguredNormalizerData extends Data { public function __construct( - #[AutoWhenLoadedLazy] - public Lazy|CountingAutoLazyChildData|null $child, + public string $id, + public string $label, ) { } } -class NonReplayAutoLazyCreationData extends Data +class CreationSourceNormalizer implements Normalizer { - public function __construct( - #[ResolvedValueAutoLazy] - public Lazy|string $value, - ) { + public function normalize(mixed $value): array|Normalized|null + { + return $value instanceof CreationSource + ? ['id' => $value->identifier, 'label' => $value->label] + : null; } } -#[Attribute(Attribute::TARGET_PROPERTY)] -class ResolvedValueAutoLazy extends AutoLazy +class CreationIdentifierCast implements Cast { - /** - * Build an automatic lazy value from a distinct resolved input. - */ - public function build( - Closure $castValue, - mixed $payload, + public function cast( DataProperty $property, mixed $value, - ): Lazy { - return Lazy::create(static fn () => $castValue('resolved')); + array $properties, + CreationContext $context, + ): int { + return 123; } } -class AutoLazyRelationModel extends Model +class CreationLabelCast implements Cast { + public function cast( + DataProperty $property, + mixed $value, + array $properties, + CreationContext $context, + ): string { + return 'cast:' . $value; + } } -class AutoLazyMorphParentCreationData extends Data +class RecordingInputsCast implements Cast { - public function __construct( - #[AutoLazy] - public Lazy|AutoLazyMorphCreationData $child, - ) { + /** @var null|array */ + public static ?array $properties = null; + + /** + * Record the declared property values and return the value. + */ + public function cast( + DataProperty $property, + mixed $value, + array $properties, + CreationContext $context, + ): string { + static::$properties = $properties; + + return $value; } } -abstract class AutoLazyMorphCreationData extends Data implements PropertyMorphableData +class RecordingItemInputsCast implements Cast, IterableItemCast { - public function __construct( - #[PropertyForMorph] - public string $type, - ) { + /** @var list> */ + public static array $received = []; + + /** + * Leave the whole property to the item casts. + */ + public function cast( + DataProperty $property, + mixed $value, + array $properties, + CreationContext $context, + ): Uncastable { + return Uncastable::create(); } - public static function morph(array $properties): ?string - { - return match ($properties['type']) { - 'plain' => AutoLazyPlainMorphCreationData::class, - 'relation' => AutoLazyRelationMorphCreationData::class, - default => null, - }; + /** + * Record the declared property values and uppercase the item. + */ + public function castIterableItem( + DataProperty $property, + mixed $value, + array $properties, + CreationContext $context, + ): string { + static::$received[] = $properties; + + return strtoupper($value); } } -class AutoLazyPlainMorphCreationData extends AutoLazyMorphCreationData +class CastInputsDefault { } -class AutoLazyRelationMorphCreationData extends AutoLazyMorphCreationData +class CastInputsData extends Data { + #[Computed] + public string $summary; + + /** + * Create a cast-inputs fixture. + */ public function __construct( - string $type, - #[AutoWhenLoadedLazy] - public Lazy|AutoLazyNormalizedChildData|null $child = null, + public int $first, + #[WithCast(RecordingInputsCast::class)] + public string $recorded, + #[MapInputName('later_value')] + public string $later, + public int $count, + public string|Optional $optional, + public ?string $nullable, + #[Config('app.name')] + public string $appName, + public CastInputsDefault $default = new CastInputsDefault, ) { - parent::__construct($type); + $this->summary = $recorded; } } -class AutoLazyDefaultCreationData extends Data +class CastInputsLeadingDefaultData extends Data { + /** + * Create a cast-inputs fixture whose default precedes the cast property. + */ public function __construct( - #[RecordingAutoLazy] - public Lazy|string $title = 'default', - #[RecordingAutoLazy] - public Lazy|ChildCreationData $child = new ChildCreationData(12), - #[RecordingAutoLazy] - public Lazy|string|null $nullable = null, - #[RecordingAutoLazy] - public Lazy|string|Optional $optional = new Optional, + public CastInputsDefault $default = new CastInputsDefault, + #[WithCast(RecordingInputsCast::class)] + public string $recorded = 'fallback', ) { } } -class AutoLazyVariantsCreationData extends Data +class CastInputsIterableData extends Data { + /** + * Create an iterable cast-inputs fixture. + * + * @param list $tags + */ public function __construct( - #[AutoClosureLazy] - public Lazy|ChildCreationData $closure, - #[AutoInertiaLazy] - public Lazy|ChildCreationData $inertia, - #[AutoInertiaDeferred('analytics', rescue: true)] - public Lazy|ChildCreationData $deferred, + public string $name, + #[WithCast(RecordingItemInputsCast::class)] + public array $tags, ) { } } -class FinishedCollectionCreationData extends Data +class CastInputsLazyData extends Data { /** - * Create a finished-collection fixture. - * - * @param Collection $collection + * Create a deferred cast-inputs fixture. */ public function __construct( - #[DataCollectionOf(ChildCreationData::class)] - public DataCollection $dataCollection, - #[DataCollectionOf(ChildCreationData::class)] - public Collection $collection, + public int $first, + #[AutoLazy, WithCast(RecordingInputsCast::class)] + public Lazy|string $recorded, ) { } } -class DataCollectionCreationData extends Data +class FactoryCastInputsLazyData extends Data { /** - * Create a data-collection construction fixture. - * - * @param DataCollection $children + * Create a deferred fixture for a factory-supplied cast. */ public function __construct( - #[DataCollectionOf(ChildCreationData::class)] - public DataCollection $children, + public int $first, + #[AutoLazy] + public Lazy|string $recorded, ) { } } -class DataPaginatorCreationData extends Data +class PreparedAddressData extends Data { + public static int $calls = 0; + /** - * Create a paginated data fixture. - * - * @param Paginator $children + * Create a prepared address fixture. */ public function __construct( - #[DataCollectionOf(ChildCreationData::class)] - public Paginator $children, + public ?string $name = null, + public ?string $address = null, ) { } -} -class ScalarPaginatorCreationData extends Data -{ /** - * Create a scalar paginator fixture. - * - * @param Paginator $ids + * Join the flat address fields into one address. */ - public function __construct(public Paginator $ids) + public static function prepareForPipeline(array $properties): array { - } -} - -class ScalarIterableCreationData extends Data -{ - /** @var list */ - public array $ids; - - /** @var list */ - public array $statuses; + ++static::$calls; - /** @var list */ - public array $dates; + $properties['address'] = implode(',', Arr::only($properties, ['line_1', 'city', 'state', 'zipcode'])); - public function __construct(array $ids, array $statuses, array $dates) - { - $this->ids = $ids; - $this->statuses = $statuses; - $this->dates = $dates; + return $properties; } } -class IntegerRangeCreationData extends Data +class PreparedRequiredAddressData extends Data { /** - * Create an integer-range fixture. - * - * @param int<0, max> $value + * Create a prepared required-address fixture. */ - public function __construct(public int $value) + public function __construct(public string $address) { } -} -class NonEmptyArrayCreationData extends Data -{ /** - * Create a non-empty array fixture. - * - * @param non-empty-array $children + * Join the flat address fields into the required address. */ - public function __construct(public array $children) + public static function prepareForPipeline(array $properties): array { + $properties['address'] = implode(', ', Arr::only($properties, ['line_1', 'city'])); + + return $properties; } } -enum CreationStatus: string +class PreparedOwnerData extends Data { - case Active = 'active'; - case Inactive = 'inactive'; + /** + * Create a fixture whose nested owner prepares its own input. + */ + public function __construct( + public string $label, + public PreparedRequiredAddressData $owner, + ) { + } } -enum IntegerCreationStatus: int +class PreparedNameModel extends Model { - case Active = 1; } -class CreationDto extends Dto +class PreparedNameData extends Data { - public function __construct( - public int $id, - ) { + /** + * Create a prepared name fixture. + */ + public function __construct(public string $name) + { } -} -class CreationResource extends Resource -{ - public function __construct( - public int $id, - ) { + /** + * Uppercase the name before it is read. + */ + public static function prepareForPipeline(array $properties): array + { + $properties['name'] = strtoupper($properties['name']); + + return $properties; } } -class NamedFactoryCreationData extends Data +abstract class PreparedShapeData extends Data implements PropertyMorphableData { + /** + * Create a prepared shape fixture. + */ public function __construct( - public string $value, + #[PropertyForMorph] + public string $type, ) { } - public static function fromString(string $value): self + /** + * Resolve the concrete shape class. + */ + public static function morph(array $properties): ?string { - return new self('direct:' . $value); + return $properties['type'] === 'circle' ? PreparedCircleData::class : null; } +} - public static function fromNumber(int $value): array +class PreparedCircleData extends PreparedShapeData +{ + /** + * Create a prepared circle fixture. + */ + public function __construct(string $type, public int $radius) { - return ['value' => 'number:' . $value]; + parent::__construct($type); + } + + /** + * Read the radius from its short input name. + */ + public static function prepareForPipeline(array $properties): array + { + $properties['radius'] ??= $properties['r'] ?? null; + + return $properties; } } -class InjectedFactoryCreationData extends Data +class CastOwnedChildData extends Data { + /** + * Create a child whose input name is mapped. + */ public function __construct( - public string $value, + #[MapInputName('external_name')] + public string $name, ) { } - public static function fromInjected( - string $value, - NamedFactoryCreationDependency $dependency, - CreationContext $context, - ): self { - return new self($value . ':' . $dependency->value . ':' . $context->dataClass); + /** + * Fail if the child's own factory runs for a value the cast accepts. + */ + public static function fromString(string $value): self + { + throw new RuntimeException('The named factory must not run when the cast accepts the value.'); } } -class NamedFactoryCreationDependency +class CastOwnedChildModel extends Model { - public string $value = 'dependency'; } -class ContextualCreationData extends Data +class CastOwnedNestingData extends Data { + /** + * Create a parent that holds a child. + */ public function __construct( - #[Config('app.name')] - public string $name, - public int $id, + public CastOwnedChildData $child, ) { } } -abstract class ShapeCreationData extends Data implements PropertyMorphableData +class CastOwnedWrapperData extends Data { + /** + * Create a fixture whose cast owns a parent that holds a child. + */ public function __construct( - #[PropertyForMorph] - public string $type, + #[WithCast(CastOwnedChildCast::class)] + public CastOwnedNestingData $wrapper, ) { } - - public static function morph(array $properties): ?string - { - return match ($properties['type']) { - 'circle' => CircleCreationData::class, - 'square' => SquareCreationData::class, - 'invalid' => ChildCreationData::class, - default => null, - }; - } } -class CircleCreationData extends ShapeCreationData +class CastOwnedAddressData extends Data { - public function __construct(string $type, public int $radius) + /** + * Create an address built from its parts. + */ + public function __construct(public string $address) { - parent::__construct($type); } -} -class SquareCreationData extends ShapeCreationData -{ - public function __construct(string $type, public int $side) + /** + * Combine the address parts before the address is read. + */ + public static function prepareForPipeline(array $properties): array { - parent::__construct($type); + $properties['address'] ??= "{$properties['line_1']}, {$properties['city']}"; + + return $properties; } } -abstract class DefaultShapeCreationData extends Data implements PropertyMorphableData +class CastOwnedInnerData extends Data { + /** + * Create a value holding a union child and a prepared child. + */ public function __construct( - #[PropertyForMorph] - public CreationStatus $status = CreationStatus::Active, + public array|CastOwnedChildData $child, + public ?CastOwnedAddressData $address = null, ) { } +} - public static function morph(array $properties): ?string - { - return match ($properties['status']) { - CreationStatus::Active => DefaultCircleCreationData::class, - default => null, - }; +class CastOwnedOuterData extends Data +{ + /** + * Create a fixture whose cast owns a value with nested children. + */ + public function __construct( + #[WithCast(CastOwnedChildCast::class)] + public CastOwnedInnerData $inner, + ) { } } -class DefaultCircleCreationData extends DefaultShapeCreationData +class CastOwnedLazyChildData extends Data { + /** + * Create a child whose relation is included only once it is loaded. + */ public function __construct( - public int $radius, - CreationStatus $status = CreationStatus::Active, + public string $name, + #[AutoWhenLoadedLazy] + public Lazy|CastOwnedChildData|null $relation, ) { - parent::__construct($status); } } -abstract class IntegerShapeCreationData extends Data implements PropertyMorphableData +class CastOwnedLazyParentData extends Data { + /** + * Create a fixture whose cast owns a child read from a model. + */ public function __construct( - #[PropertyForMorph] - public IntegerCreationStatus $status, + #[WithCast(CastOwnedChildCast::class)] + public CastOwnedLazyChildData $child, ) { } +} - public static function morph(array $properties): ?string +class CountingNameNormalizer implements Normalizer +{ + public static int $calls = 0; + + /** + * Read a plain string as a name, counting each read. + */ + public function normalize(mixed $value): array|Normalized|null { - return match ($properties['status']) { - IntegerCreationStatus::Active => IntegerCircleCreationData::class, - }; + if (! is_string($value)) { + return null; + } + + ++self::$calls; + + return ['name' => $value]; } } -class IntegerCircleCreationData extends IntegerShapeCreationData +class NormalizedNameChildData extends Data { - public function __construct(IntegerCreationStatus $status, public int $radius) + /** + * Create a child read from a plain name. + */ + public function __construct(public string $name) { - parent::__construct($status); + } + + /** + * Get the normalizers that read this child's input. + * + * @return list> + */ + public static function normalizers(): array + { + return [CountingNameNormalizer::class]; } } -class ShapeListCreationData extends Data +class CastOwnedNormalizedParentData extends Data { /** - * Create a shape-list fixture. - * - * @param array $shapes + * Create a fixture whose cast owns a child read from a plain name. */ public function __construct( - #[DataCollectionOf(ShapeCreationData::class)] - public array $shapes, + #[WithCast(CastOwnedChildCast::class)] + public NormalizedNameChildData $child, ) { } } -class MappedItemCreationData extends Data +class PendingFactorySource { - public function __construct( - #[MapInputName('profile.name')] - public string $name, - ) { + /** + * Create a source a named factory reads. + */ + public function __construct(public string $name) + { } } -class MappedItemListCreationData extends Data +class PendingFactoryChildData extends Data { + public static int $calls = 0; + /** - * Create a mapped-item list fixture. - * - * @param array $items + * Create a child. */ - public function __construct( - #[DataCollectionOf(MappedItemCreationData::class)] - public array $items, - ) { + public function __construct(public string $name) + { + } + + /** + * Count each run while building the child from its source. + */ + public static function fromSource(PendingFactorySource $source): self + { + ++self::$calls; + + return new self("factory:{$source->name}"); } } -class AlternateChildCreationData extends Data +class PendingFactoryWrapperData extends Data { + /** + * Create a wrapper whose children may come from a named factory. + */ public function __construct( - public int $id, + public PendingFactoryChildData $child, + #[Exclude] + public ?PendingFactoryChildData $excluded = null, ) { } } -class AmbiguousCreationData extends Data +class AcceptingWrapperCast implements Cast { - public function __construct( - public ChildCreationData|AlternateChildCreationData $child, - ) { + /** + * Supply the wrapper without creating anything from the input. + */ + public function cast(DataProperty $property, mixed $value, array $properties, CreationContext $context): PendingFactoryWrapperData + { + return new PendingFactoryWrapperData(new PendingFactoryChildData('cast')); } } -class AmbiguousDataBeforeDateCreationData extends Data +class PendingFactoryAcceptedData extends Data { + /** + * Create a fixture whose cast accepts the wrapper. + */ public function __construct( - public ChildCreationData|AlternateChildCreationData|DateTimeImmutable $value, + #[WithCast(AcceptingWrapperCast::class)] + public PendingFactoryWrapperData $wrapper, ) { } } -class CastableBeforeDateCreationData extends Data +class PendingFactoryDeclinedData extends Data { + /** + * Create a fixture whose cast declines the wrapper. + */ public function __construct( - public PriorityCreationCastable|DateTimeImmutable $value, + #[WithCast(CastOwnedChildCast::class)] + public PendingFactoryWrapperData $wrapper, ) { } } -class AmbiguousDateBeforeEnumCreationData extends Data +class CastOwnedChildCast implements Cast { - public function __construct( - public DateTimeImmutable|DateTime|PriorityCreationStatus $value, - ) { + public static mixed $received = null; + + /** + * Record the value and build a child only from a string. + */ + public function cast(DataProperty $property, mixed $value, array $properties, CreationContext $context): mixed + { + static::$received = $value; + + return is_string($value) ? new CastOwnedChildData("cast:{$value}") : Uncastable::create(); } } -class AmbiguousEnumBeforeBuiltinCreationData extends Data +class CastOwnedParentData extends Data { + /** + * Create a parent whose child is owned by a cast. + */ public function __construct( - public PriorityCreationStatus|AlternatePriorityCreationStatus|int $value, + #[WithCast(CastOwnedChildCast::class)] + public CastOwnedChildData $child, ) { } } -class PriorityCreationCastable implements Castable +abstract class CastOwnedShapeData extends Data implements PropertyMorphableData { + /** + * Create a property-morphable shape. + */ public function __construct( - public readonly string $value, + #[PropertyForMorph] + public string $type, ) { } /** - * Create the cast for this type. + * Resolve the concrete shape class. */ - public static function dataCastUsing(array $arguments): Cast + public static function morph(array $properties): ?string { - return new PriorityCreationCast; + return $properties['type'] === 'square' ? CastOwnedSquareData::class : null; } } -class PriorityCreationCast implements Cast +class CastOwnedSquareData extends CastOwnedShapeData { /** - * Cast a value into the declared Castable type. + * Create a square with a concrete-only rule. */ - public function cast( - DataProperty $property, - mixed $value, - ConstructionState $state, - CreationContext $context, - ): PriorityCreationCastable { - return new PriorityCreationCastable((string) $value); + public function __construct( + #[Min(3)] + public string $code, + ) { + parent::__construct('square'); } } -enum PriorityCreationStatus: string -{ - case Active = 'active'; -} - -enum AlternatePriorityCreationStatus: string -{ - case Inactive = 'inactive'; -} - -class AmbiguousDataCollectableCreationData extends Data +class CastOwnedShapesData extends Data { /** - * Create an ambiguous data-collectable fixture. + * Create a fixture with cast-owned polymorphic properties. * - * @param Collection|DataCollection|string $children + * @param array $shapes */ public function __construct( - public Collection|DataCollection|string $children, + #[WithCast(CastOwnedChildCast::class)] + public CastOwnedShapeData $shape, + #[WithCast(CastOwnedChildCast::class), DataCollectionOf(CastOwnedShapeData::class)] + public array $shapes, ) { } } -class CastedAmbiguousDataCollectableCreationData extends Data +class CastOwnedUnionData extends Data { /** - * Create a cast-owned ambiguous data-collectable fixture. + * Create a fixture whose cast-owned values the cast declines. * - * @param Collection|DataCollection $children + * @param array|CastOwnedChildData $numbers + * @param null|LazyCollection $children */ public function __construct( - #[WithCast(AmbiguousDataCollectableCreationCast::class)] - public Collection|DataCollection $children, + #[WithCast(CastOwnedChildCast::class)] + public array|CastOwnedChildData $child, + #[WithCast(CastOwnedChildCast::class)] + public array|CastOwnedChildData $numbers, + #[WithCast(CastOwnedChildCast::class)] + public ?LazyCollection $children = null, ) { } } -class AmbiguousDataCollectableCreationCast implements Cast +class ConstructorInputCreationData extends Data { - public function cast( - DataProperty $property, - mixed $value, - ConstructionState $state, - CreationContext $context, - ): Collection { - return new Collection([new ChildCreationData((int) $value)]); - } -} + public array $received; -class CreationSource -{ - public function __construct( - public readonly string $label, - public readonly string $identifier, - ) { + /** + * Create a fixture whose constructor parameters have no public data properties. + * + * @param array $options + */ + public function __construct(string $prefix, array $options = []) + { + $this->received = ['prefix' => $prefix, 'options' => $options]; } } -class CustomizedCreationData extends Data +class NonPublicPromotedCreationData extends Data { + /** + * Create a fixture with protected and private promoted state. + */ public function __construct( - #[WithCast(CreationIdentifierCast::class)] - public int $id, - #[WithCast(CreationLabelCast::class)] - public string $label, + public string $name, + protected string $role = 'member', + private bool $trusted = false, ) { } - public static function normalizers(): array + /** + * Get the non-public promoted state. + * + * @return array{role: string, trusted: bool} + */ + public function state(): array { - return [CreationSourceNormalizer::class]; + return ['role' => $this->role, 'trusted' => $this->trusted]; } } -class ConfiguredNormalizerData extends Data +#[FailOnUnknownFields] +class StrictNonPublicPromotedCreationData extends Data { + /** + * Create a strict fixture with protected promoted state. + */ public function __construct( - public string $id, - public string $label, + public string $name, + protected string $role = 'member', ) { } } -class CreationSourceNormalizer implements Normalizer +class RequiredNonPublicPromotedCreationData extends Data { - public function normalize(mixed $value): array|Normalized|null - { - return $value instanceof CreationSource - ? ['id' => $value->identifier, 'label' => $value->label] - : null; + /** + * Create a fixture whose only non-public promoted parameter is required. + */ + public function __construct( + public string $name, + private string $secret, + ) { } } -class CreationIdentifierCast implements Cast +class RequiredNonPublicPromotedWithInputCreationData extends Data { - public function cast( - DataProperty $property, - mixed $value, - ConstructionState $state, - CreationContext $context, - ): int { - return 123; + /** + * Create a fixture with a constructor input and a required non-public promoted parameter. + */ + public function __construct( + public string $name, + string $prefix, + protected string $secret, + ) { } } -class CreationLabelCast implements Cast +class ConstructorAssignedCreationData extends Data { - public function cast( - DataProperty $property, - mixed $value, - ConstructionState $state, - CreationContext $context, - ): string { - return 'cast:' . $value; + public ?string $nickname; + + public string|Optional $label; + + /** + * Create a fixture whose constructor assigns an unbound property. + */ + public function __construct(public string $name) + { + $this->label = "label:{$name}"; } } diff --git a/tests/Data/Support/Creation/DataInstantiatorTest.php b/tests/Data/Support/Creation/DataInstantiatorTest.php index 1419ab5c1c..a251894f3e 100644 --- a/tests/Data/Support/Creation/DataInstantiatorTest.php +++ b/tests/Data/Support/Creation/DataInstantiatorTest.php @@ -11,6 +11,7 @@ use Hypervel\Contracts\Container\ContextualAttribute; use Hypervel\Data\Data; use Hypervel\Data\Exceptions\CannotCreateData; +use Hypervel\Data\Optional; use Hypervel\Data\Support\Annotations\DataIterableAnnotationReader; use Hypervel\Data\Support\Creation\DataInstantiator; use Hypervel\Data\Support\DataClass; @@ -132,6 +133,48 @@ public function testThrowsForMissingUnboundPropertyValues(): void ); } + public function testResolvesUnboundPropertiesAfterConstruction(): void + { + $instantiator = new DataInstantiator(new Container); + $metadata = $this->metadata(InstantiatorConstructorAssignedDataFixture::class); + + $absent = $instantiator->instantiate($metadata, ['name' => 'Taylor']); + + $this->assertSame('assigned:Taylor', $absent->assigned); + $this->assertSame('nullable:Taylor', $absent->nullableAssigned); + $this->assertInstanceOf(Optional::class, $absent->optional); + $this->assertNull($absent->nullable); + + $optional = $instantiator->instantiate($metadata, [ + 'name' => 'Taylor', + 'assigned' => Optional::create(), + 'optional' => Optional::create(), + ]); + + $this->assertSame('assigned:Taylor', $optional->assigned); + $this->assertInstanceOf(Optional::class, $optional->optional); + + $supplied = $instantiator->instantiate($metadata, [ + 'name' => 'Taylor', + 'assigned' => 'input', + 'nullableAssigned' => null, + ]); + + $this->assertSame('input', $supplied->assigned); + $this->assertNull($supplied->nullableAssigned); + } + + public function testAncestorPromotedPropertiesIgnoreInput(): void + { + $data = (new DataInstantiator(new Container))->instantiate( + $this->metadata(InstantiatorAncestorChildDataFixture::class), + ['name' => 'Taylor', 'kind' => 'input'], + ); + + $this->assertSame('child', $data->kind); + $this->assertSame('Taylor', $data->name); + } + /** * Test ordinary construction cannot bypass a non-public constructor. */ @@ -172,7 +215,7 @@ protected function metadata(string $class): DataClass $config = new DataConfig(new Repository(['data' => $defaults])); $container = new Container; $nameMapperResolver = new NameMapperResolver($container); - $typeFactory = new DataTypeFactory(new PhpDocTypeNameResolver); + $typeFactory = new DataTypeFactory(new PhpDocTypeNameResolver, new DataIterableAnnotationReader); $parameterFactory = new DataParameterFactory($typeFactory); return (new DataClassFactory( @@ -261,6 +304,47 @@ class InstantiatorUnboundDataFixture extends Data public string $required; } +class InstantiatorConstructorAssignedDataFixture extends Data +{ + public string|Optional $assigned; + + public ?string $nullableAssigned; + + public string|Optional $optional; + + public ?string $nullable; + + /** + * Create a fixture whose constructor assigns unbound properties. + */ + public function __construct(public string $name) + { + $this->assigned = "assigned:{$name}"; + $this->nullableAssigned = "nullable:{$name}"; + } +} + +abstract class InstantiatorAncestorDataFixture extends Data +{ + /** + * Create a fixture whose kind is promoted by the ancestor constructor. + */ + public function __construct(public readonly string $kind) + { + } +} + +class InstantiatorAncestorChildDataFixture extends InstantiatorAncestorDataFixture +{ + /** + * Create a child that fixes its kind through the ancestor constructor. + */ + public function __construct(public string $name) + { + parent::__construct('child'); + } +} + class InstantiatorPrivateDataFixture extends Data { public readonly string $name; diff --git a/tests/Data/Support/Creation/SourceResolverTest.php b/tests/Data/Support/Creation/SourceResolverTest.php index ccebad71d8..293f2f12f5 100644 --- a/tests/Data/Support/Creation/SourceResolverTest.php +++ b/tests/Data/Support/Creation/SourceResolverTest.php @@ -5,7 +5,6 @@ namespace Hypervel\Tests\Data\Support\Creation; use Hypervel\Contracts\Support\Arrayable; -use Hypervel\Data\Exceptions\CannotCreateData; use Hypervel\Data\Normalizers\Normalized\Normalized; use Hypervel\Data\Normalizers\Normalized\NormalizedModel; use Hypervel\Data\Normalizers\Normalized\UnknownProperty; @@ -36,15 +35,26 @@ public function normalize(mixed $value): array|Normalized|null } }; - $this->assertSame([], SourceResolver::resolve(self::class, null, [$normalizer])); - $this->assertSame($normalized, SourceResolver::resolve(self::class, $normalized, [$normalizer])); + $this->assertNull(SourceResolver::normalize(null, [$normalizer])); + $this->assertNull(SourceResolver::normalize($normalized, [$normalizer])); + $this->assertSame([], SourceResolver::resolve(null)); + $this->assertSame($normalized, SourceResolver::resolve($normalized)); } /** - * Test class and configured normalizers run before fixed source handling. + * Test the first non-null custom normalizer result wins, including an empty array. */ - public function testFirstCustomNormalizerWinsBeforeFixedArrayHandling(): void + public function testFirstCustomNormalizerResultWins(): void { + $empty = new class implements Normalizer { + /** + * Read every value as empty input. + */ + public function normalize(mixed $value): array|Normalized|null + { + return []; + } + }; $skipped = new class implements Normalizer { public function normalize(mixed $value): array|Normalized|null { @@ -60,8 +70,10 @@ public function normalize(mixed $value): array|Normalized|null $this->assertSame( ['custom' => 'value'], - SourceResolver::resolve(self::class, ['original' => 'value'], [$skipped, $accepted]), + SourceResolver::normalize(['original' => 'value'], [$skipped, $accepted, $empty]), ); + $this->assertSame([], SourceResolver::normalize(['original' => 'value'], [$skipped, $empty, $accepted])); + $this->assertNull(SourceResolver::normalize(['original' => 'value'], [$skipped])); } /** @@ -88,26 +100,18 @@ public function toArray(): array $model = new class extends Model { }; - $this->assertSame(['array' => true], SourceResolver::resolve(self::class, ['array' => true], [])); - $this->assertSame(['request' => true], SourceResolver::resolve(self::class, $request, [])); - $this->assertSame(['source' => 'arrayable'], SourceResolver::resolve(self::class, $arrayable, [])); - $this->assertSame(['initialized' => 'value'], SourceResolver::resolve(self::class, $object, [])); - $this->assertSame(['json' => true], SourceResolver::resolve(self::class, '{"json":true}', [])); - $this->assertInstanceOf(NormalizedModel::class, SourceResolver::resolve(self::class, $model, [])); + $this->assertSame(['array' => true], SourceResolver::resolve(['array' => true])); + $this->assertSame(['request' => true], SourceResolver::resolve($request)); + $this->assertSame(['source' => 'arrayable'], SourceResolver::resolve($arrayable)); + $this->assertSame(['initialized' => 'value'], SourceResolver::resolve($object)); + $this->assertSame(['json' => true], SourceResolver::resolve('{"json":true}')); + $this->assertInstanceOf(NormalizedModel::class, SourceResolver::resolve($model)); } - /** - * Test unsupported and invalid JSON values fail with a creation exception. - */ - public function testThrowsWhenNoFixedOrCustomNormalizerAcceptsTheValue(): void + public function testReturnsNullWhenFixedHandlingCannotReadTheValue(): void { foreach ([42, 'not-json', 'null'] as $value) { - try { - SourceResolver::resolve(self::class, $value, []); - $this->fail('Expected the source to be rejected.'); - } catch (CannotCreateData $exception) { - $this->assertStringContainsString('no normalizer accepted', $exception->getMessage()); - } + $this->assertNull(SourceResolver::resolve($value)); } } } diff --git a/tests/Data/Support/DataAttributesCollectionTest.php b/tests/Data/Support/DataAttributesCollectionTest.php index 29cd84ccb4..de5f5568d6 100644 --- a/tests/Data/Support/DataAttributesCollectionTest.php +++ b/tests/Data/Support/DataAttributesCollectionTest.php @@ -2,11 +2,20 @@ declare(strict_types=1); -namespace Hypervel\Tests\Data\Support; +namespace Hypervel\Tests\Data\Support\DataAttributesCollectionTest; use Attribute; +use Hypervel\Data\Attributes\AutoClosureLazy; +use Hypervel\Data\Attributes\AutoLazy; +use Hypervel\Data\Attributes\GetsCast; +use Hypervel\Data\Attributes\MapInputName; +use Hypervel\Data\Attributes\MapOutputName; +use Hypervel\Data\Attributes\WithCast; +use Hypervel\Data\Mappers\CamelCaseMapper; use Hypervel\Data\Support\Factories\DataAttributesCollectionFactory; +use Hypervel\Tests\Data\Fixtures\Casts\ConfidentialDataCast; use Hypervel\Tests\TestCase; +use ReflectionAttribute; use ReflectionClass; use ReflectionMethod; use ReflectionProperty; @@ -14,40 +23,112 @@ class DataAttributesCollectionTest extends TestCase { - /** - * Test that attribute constructors remain lazy recipes. - */ - public function testAttributeConstructorsAreNotRunWhileMetadataIsGrouped(): void + // The collection holds attribute recipes, which create the attribute on newInstance(), where Spatie's holds + // the attribute objects. + + public function testCanGetTheAttributesFromAReflectionClass(): void { $attributes = DataAttributesCollectionFactory::buildFromReflectionClass( - new ReflectionClass(DataAttributesClassWithThrowingAttribute::class), + new ReflectionClass(TestClassWithAttribute::class) ); - $this->assertTrue($attributes->has(DataAttributesThrowingAttribute::class)); + $this->assertTrue($attributes->has(MapInputName::class)); + $this->assertInstanceOf(MapInputName::class, $attributes->first(MapInputName::class)?->newInstance()); + } - $this->expectException(RuntimeException::class); + public function testCanGetAttributesFromAReflectionClassAndItsParents(): void + { + $attributes = DataAttributesCollectionFactory::buildFromReflectionClass( + new ReflectionClass(TestInheritedClassWithAttribute::class) + ); - $attributes->first(DataAttributesThrowingAttribute::class)?->newInstance(); + $this->assertTrue($attributes->has(MapInputName::class)); + $this->assertInstanceOf(MapInputName::class, $attributes->first(MapInputName::class)?->newInstance()); + + $this->assertTrue($attributes->has(MapOutputName::class)); + $this->assertInstanceOf(MapOutputName::class, $attributes->first(MapOutputName::class)?->newInstance()); + } + + public function testCanGetAttributesForAReflectionProperty(): void + { + $class = new class { + #[MapInputName(CamelCaseMapper::class)] + protected string $first_name; + }; + + $attributes = DataAttributesCollectionFactory::buildFromReflectionProperty( + new ReflectionProperty($class, 'first_name') + ); + + $this->assertTrue($attributes->has(MapInputName::class)); + $this->assertInstanceOf(MapInputName::class, $attributes->first(MapInputName::class)?->newInstance()); + } + + public function testCanGetMultipleVersionsOfAnAttributeAttributesForAReflectionProperty(): void + { + $class = new class { + #[RepeatableAttribute('a')] + #[RepeatableAttribute('b')] + protected string $first_name; + }; + + $attributes = DataAttributesCollectionFactory::buildFromReflectionProperty( + new ReflectionProperty($class, 'first_name') + ); + + $this->assertTrue($attributes->has(RepeatableAttribute::class)); + $this->assertEquals( + [new RepeatableAttribute('a'), new RepeatableAttribute('b')], + array_map(fn (ReflectionAttribute $attribute): object => $attribute->newInstance(), $attributes->all(RepeatableAttribute::class)), + ); + } + + public function testCanGetTheAttributeByItsParentClass(): void + { + $class = new class { + #[AutoClosureLazy] + protected string $first_name; + }; + + $attributes = DataAttributesCollectionFactory::buildFromReflectionProperty( + new ReflectionProperty($class, 'first_name') + ); + + $this->assertTrue($attributes->has(AutoLazy::class)); + $this->assertInstanceOf(AutoClosureLazy::class, $attributes->first(AutoLazy::class)?->newInstance()); + $this->assertSame($attributes->first(AutoClosureLazy::class), $attributes->first(AutoLazy::class)); + } + + public function testCanGetTheAttributeByItsInterface(): void + { + $class = new class { + #[WithCast(ConfidentialDataCast::class)] + protected string $first_name; + }; + + $attributes = DataAttributesCollectionFactory::buildFromReflectionProperty( + new ReflectionProperty($class, 'first_name') + ); + + $this->assertTrue($attributes->has(GetsCast::class)); + $this->assertInstanceOf(WithCast::class, $attributes->first(GetsCast::class)?->newInstance()); + $this->assertSame($attributes->first(WithCast::class), $attributes->first(GetsCast::class)); } /** - * Test that recipes are grouped by concrete, parent, and interface types. + * Test that attribute constructors remain lazy recipes. */ - public function testAttributesAreGroupedByConcreteParentAndInterfaceTypes(): void + public function testAttributeConstructorsAreNotRunWhileMetadataIsGrouped(): void { - $attributes = DataAttributesCollectionFactory::buildFromReflectionProperty( - new ReflectionProperty(DataAttributesPropertyFixture::class, 'value'), + $attributes = DataAttributesCollectionFactory::buildFromReflectionClass( + new ReflectionClass(DataAttributesClassWithThrowingAttribute::class), ); - $concrete = $attributes->first(DataAttributesConcreteAttribute::class); + $this->assertTrue($attributes->has(DataAttributesThrowingAttribute::class)); - $this->assertNotNull($concrete); - $this->assertSame($concrete, $attributes->first(DataAttributesBaseAttribute::class)); - $this->assertSame($concrete, $attributes->first(DataAttributesAttributeContract::class)); - $this->assertSame(['first', 'second'], array_map( - fn ($attribute): string => $attribute->newInstance()->name, - $attributes->all(DataAttributesConcreteAttribute::class), - )); + $this->expectException(RuntimeException::class); + + $attributes->first(DataAttributesThrowingAttribute::class)?->newInstance(); } /** @@ -74,7 +155,7 @@ public function testUnknownAttributesAreIgnored(): void new ReflectionClass(DataAttributesUnknownAttributeFixture::class), ); - $this->assertFalse($attributes->has('Hypervel\Tests\Data\Support\MissingAttribute')); + $this->assertFalse($attributes->has('Hypervel\Tests\Data\Support\DataAttributesCollectionTest\MissingAttribute')); } /** @@ -98,16 +179,29 @@ public function testParameterAttributeArgumentsAreRecreatedForEachInstantiation( } } -interface DataAttributesAttributeContract +#[MapInputName(CamelCaseMapper::class)] +class TestClassWithAttribute { } -class DataAttributesBaseAttribute +#[MapOutputName(CamelCaseMapper::class)] +class TestInheritedClassWithAttribute extends TestClassWithAttribute { } #[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)] -class DataAttributesConcreteAttribute extends DataAttributesBaseAttribute implements DataAttributesAttributeContract +class RepeatableAttribute +{ + /** + * Create a repeatable test attribute. + */ + public function __construct(public string $name) + { + } +} + +#[Attribute(Attribute::TARGET_CLASS)] +class DataAttributesConcreteAttribute { /** * Create a new concrete test attribute. @@ -155,13 +249,6 @@ class DataAttributesClassWithThrowingAttribute { } -class DataAttributesPropertyFixture -{ - #[DataAttributesConcreteAttribute('first')] - #[DataAttributesConcreteAttribute('second')] - public string $value; -} - #[DataAttributesConcreteAttribute('parent')] class DataAttributesParentFixture { diff --git a/tests/Data/Support/DataClassRepositoryTest.php b/tests/Data/Support/DataClassRepositoryTest.php index de479951da..ba74c1f3a5 100644 --- a/tests/Data/Support/DataClassRepositoryTest.php +++ b/tests/Data/Support/DataClassRepositoryTest.php @@ -78,7 +78,16 @@ public function testDynamicRuleGraphsSkipNonValidatingProperties(): void $repository = $this->repository(); $this->assertFalse($repository->hasDynamicRuleGraph(SkippedDynamicRepositoryDataFixture::class)); - $this->assertFalse($repository->hasDynamicRuleGraph(ContextualDynamicRepositoryDataFixture::class)); + } + + /** + * Test contextual properties, which are validated with the object, join the rule graph. + */ + public function testDynamicRuleGraphsFollowContextualProperties(): void + { + $this->assertTrue( + $this->repository()->hasDynamicRuleGraph(ContextualDynamicRepositoryDataFixture::class), + ); } /** @@ -110,7 +119,7 @@ protected function repository(): DataClassRepository $defaults = require __DIR__ . '/../../../src/data/config/data.php'; $config = new DataConfig(new Repository(['data' => $defaults])); $nameMapperResolver = new NameMapperResolver(new Container); - $typeFactory = new DataTypeFactory(new PhpDocTypeNameResolver); + $typeFactory = new DataTypeFactory(new PhpDocTypeNameResolver, new DataIterableAnnotationReader); $parameterFactory = new DataParameterFactory($typeFactory); return new DataClassRepository(new DataClassFactory( diff --git a/tests/Data/Support/DataClassTest.php b/tests/Data/Support/DataClassTest.php index b3db4f062b..d4890b4ca0 100644 --- a/tests/Data/Support/DataClassTest.php +++ b/tests/Data/Support/DataClassTest.php @@ -9,6 +9,7 @@ use Hypervel\Config\Repository; use Hypervel\Container\Container; use Hypervel\Contracts\Container\ContextualAttribute; +use Hypervel\Contracts\Foundation\Application; use Hypervel\Data\Attributes\AutoLazy; use Hypervel\Data\Attributes\Computed; use Hypervel\Data\Attributes\DataCollectionOf; @@ -24,6 +25,7 @@ use Hypervel\Data\Contracts\PropertyMorphableData; use Hypervel\Data\Data; use Hypervel\Data\DataCollection; +use Hypervel\Data\DataServiceProvider; use Hypervel\Data\Dto; use Hypervel\Data\Enums\DataPropertyOperation; use Hypervel\Data\Exceptions\InvalidDataDeclaration; @@ -32,9 +34,9 @@ use Hypervel\Data\Normalizers\Normalized\Normalized; use Hypervel\Data\Normalizers\Normalizer; use Hypervel\Data\Support\Annotations\DataIterableAnnotationReader; -use Hypervel\Data\Support\Creation\ConstructionState; use Hypervel\Data\Support\Creation\CreationContext; use Hypervel\Data\Support\DataConfig; +use Hypervel\Data\Support\DataMethod; use Hypervel\Data\Support\DataProperty; use Hypervel\Data\Support\Factories\DataClassFactory; use Hypervel\Data\Support\Factories\DataMethodFactory; @@ -52,19 +54,97 @@ use Hypervel\Foundation\Http\Attributes\RedirectTo; use Hypervel\Foundation\Http\Attributes\RedirectToRoute; use Hypervel\Foundation\Http\Attributes\StopOnFirstFailure; +use Hypervel\Testbench\TestCase; use Hypervel\Tests\Data\Fixtures\DataClassAnnotations\ChildScope\ChildAnnotations; use Hypervel\Tests\Data\Fixtures\DataClassAnnotations\Items\ChildClassItem; use Hypervel\Tests\Data\Fixtures\DataClassAnnotations\Items\ConstructorItem; use Hypervel\Tests\Data\Fixtures\DataClassAnnotations\Items\InlineItem; use Hypervel\Tests\Data\Fixtures\DataClassAnnotations\Items\ParentClassItem; -use Hypervel\Tests\TestCase; +use Hypervel\Tests\Data\Fixtures\DataWithMapper; +use Hypervel\Tests\Data\Fixtures\Models\DummyModel; +use Hypervel\Tests\Data\Fixtures\SimpleData; use PHPUnit\Framework\Attributes\DataProvider; use ReflectionClass; +use ReflectionMethod; use RuntimeException; use stdClass; class DataClassTest extends TestCase { + /** + * Get package providers for the test application. + */ + protected function getPackageProviders(Application $app): array + { + return [DataServiceProvider::class]; + } + + public function testKeepsTrackOfAGlobalMapFromAttribute(): void + { + $dataClass = $this->factory()->build(new ReflectionClass(DataWithMapper::class)); + + $this->assertEquals('cased_property', $dataClass->properties['casedProperty']->inputMappedName); + $this->assertEquals('cased_property', $dataClass->properties['casedProperty']->outputMappedName); + } + + public function testWillProvideInformationAboutSpecialMethods(): void + { + $class = $this->factory()->build(new ReflectionClass(SimpleData::class)); + + $this->assertArrayHasKey('fromString', $class->methods); + $this->assertInstanceOf(DataMethod::class, $class->methods['fromString']); + } + + public function testWillProvideInformationAboutTheConstructor(): void + { + $class = $this->factory()->build(new ReflectionClass(SimpleData::class)); + + $this->assertInstanceOf(ReflectionMethod::class, $class->constructor); + $this->assertSame(['string'], array_column($class->constructorParameters, 'name')); + } + + public function testWillPopulateDefaultsToPropertiesWhenTheyExist(): void + { + $dataClass = new class('', '') extends Data { + public string $property; + + public string $default_property = 'Hello'; + + /** + * Create the data object with promoted defaults. + */ + public function __construct( + public string $promoted_property, + public string $default_promoted_property = 'Hello Again', + ) { + } + }; + + $properties = array_values($this->factory()->build(new ReflectionClass($dataClass))->properties); + + // Metadata records only whether a default exists; the value itself is read when needed. + $this->assertEquals('property', $properties[0]->name); + $this->assertFalse($properties[0]->hasDefaultValue); + + $this->assertEquals('default_property', $properties[1]->name); + $this->assertTrue($properties[1]->hasDefaultValue); + + $this->assertEquals('promoted_property', $properties[2]->name); + $this->assertFalse($properties[2]->hasDefaultValue); + + $this->assertEquals('default_promoted_property', $properties[3]->name); + $this->assertTrue($properties[3]->hasDefaultValue); + } + + public function testWontThrowAnErrorIfANonExistingAttributeIsUsedOnADataClass(): void + { + $this->assertEquals('hello', PhpStormClassAttributeData::from(['property' => 'hello'])->property); + $this->assertEquals('hello', NonExistingAttributeData::from(['property' => 'hello'])->property); + $this->assertEquals('hello', PhpStormClassAttributeData::from((object) ['property' => 'hello'])->property); + $this->assertEquals('hello', PhpStormClassAttributeData::from('{"property": "hello"}')->property); + $this->assertEquals(1, ModelWithPhpStormAttributeData::from((new DummyModel)->fill(['id' => 1]))->id); + } + /** * Test class-level metadata, methods, mappings, and request attributes. */ @@ -231,7 +311,8 @@ public function testContextualParametersUseOneUnambiguousOwnershipForm(): void ); $this->assertTrue($promoted->properties['userId']->isConstructorParameter); - $this->assertFalse($promoted->properties['userId']->validate); + // A promoted contextual value is validated with the object once Fill resolves it. + $this->assertTrue($promoted->properties['userId']->validate); $this->assertSame(ContextualValue::class, $promoted->constructorParameters[0]->contextualAttribute?->getName()); $this->assertSame(['userId' => true], $promoted->contextualParameters); $this->assertNull($promoted->creationRecipe); @@ -260,6 +341,7 @@ public function testCreationRecipeEligibilityUsesCompiledClassAndPropertyFacts() AbstractDirectArrayCreationDataFixture::class, MorphableDirectArrayCreationDataFixture::class, ClassNormalizerDirectArrayCreationDataFixture::class, + PreparedDirectArrayCreationDataFixture::class, PromotedContextualDataFixture::class, AutoLazyDirectArrayCreationDataFixture::class, LoadRelationDirectArrayCreationDataFixture::class, @@ -344,11 +426,32 @@ public static function invalidDeclarationProvider(): array 'computed constructor property' => [ComputedConstructorDataFixture::class, 'declares output-only property'], 'write-only virtual property' => [WriteOnlyVirtualDataFixture::class, 'declares write-only virtual property'], 'contextual property collision' => [ContextualCollisionDataFixture::class, 'conflicts with public data property'], - 'non-public promoted property' => [NonPublicPromotedDataFixture::class, 'promotes non-public property'], - 'constructor parameter without property' => [MissingPropertyDataFixture::class, 'has no corresponding public data property'], ]; } + public function testConstructorInputsWithoutPublicPropertiesAreRecorded(): void + { + $metadata = $this->factory()->build(new ReflectionClass(MissingPropertyDataFixture::class)); + + $this->assertSame(['source'], $metadata->constructorInputs); + $this->assertSame([], $metadata->properties); + $this->assertNull($metadata->creationRecipe); + $this->assertFalse($metadata->directConstructorInstantiation); + } + + public function testNonPublicPromotedParametersAreNotConstructorInputs(): void + { + $required = $this->factory()->build(new ReflectionClass(NonPublicPromotedDataFixture::class)); + $defaulted = $this->factory()->build(new ReflectionClass(DefaultedNonPublicPromotedDataFixture::class)); + + $this->assertSame([], $required->constructorInputs); + $this->assertSame(['name'], array_keys($required->properties)); + // Nothing would supply the required parameter, so creation takes the path that reports it missing. + $this->assertFalse($required->directConstructorInstantiation); + $this->assertSame([], $defaulted->constructorInputs); + $this->assertTrue($defaulted->directConstructorInstantiation); + } + /** * Test duplicate effective mapping ownership. * @@ -451,7 +554,7 @@ protected function factory(array $overrides = []): DataClassFactory 'data' => array_replace($defaults, $overrides), ])); $nameMapperResolver = new NameMapperResolver(new Container); - $typeFactory = new DataTypeFactory(new PhpDocTypeNameResolver); + $typeFactory = new DataTypeFactory(new PhpDocTypeNameResolver, new DataIterableAnnotationReader); $parameterFactory = new DataParameterFactory($typeFactory); return new DataClassFactory( @@ -670,6 +773,17 @@ public static function normalizers(): array } } +class PreparedDirectArrayCreationDataFixture extends DirectArrayCreationDataFixture +{ + /** + * Prepare one normalized payload. + */ + public static function prepareForPipeline(array $properties): array + { + return $properties; + } +} + class AutoLazyDirectArrayCreationDataFixture extends Data { /** @@ -720,7 +834,7 @@ class DirectArrayCreationCast implements Cast public function cast( DataProperty $property, mixed $value, - ConstructionState $state, + array $properties, CreationContext $context, ): mixed { return $value; @@ -828,14 +942,27 @@ public function __construct( class NonPublicPromotedDataFixture { /** - * Create a new non-public promoted fixture. + * Create a new fixture with a required non-public promoted parameter. */ public function __construct( + public string $name, protected string $secret, ) { } } +class DefaultedNonPublicPromotedDataFixture +{ + /** + * Create a new fixture with a defaulted non-public promoted parameter. + */ + public function __construct( + public string $name, + private string $secret = 'none', + ) { + } +} + class MissingPropertyDataFixture { /** @@ -942,3 +1069,51 @@ class EloquentDnfCollectionDataFixture class ContextualValue implements ContextualAttribute { } + +#[\JetBrains\PhpStorm\Immutable] +class PhpStormClassAttributeData extends Data +{ + public readonly string $property; + + /** + * Create a fixture whose class uses an IDE attribute. + */ + public function __construct(string $property) + { + $this->property = $property; + } +} + +#[\Foo\Bar] +class NonExistingAttributeData extends Data +{ + public readonly string $property; + + /** + * Create a fixture whose class uses an undefined attribute. + */ + public function __construct(string $property) + { + $this->property = $property; + } +} + +#[\JetBrains\PhpStorm\Immutable] +class ModelWithPhpStormAttributeData extends Data +{ + /** + * Create a model fixture whose class uses an IDE attribute. + */ + public function __construct( + public int $id + ) { + } + + /** + * Create the fixture from a dummy model. + */ + public static function fromDummyModel(DummyModel $model): self + { + return new self($model->id); + } +} diff --git a/tests/Data/Support/DataIterableAnnotationReaderTest.php b/tests/Data/Support/DataIterableAnnotationReaderTest.php deleted file mode 100644 index 42f2152ac8..0000000000 --- a/tests/Data/Support/DataIterableAnnotationReaderTest.php +++ /dev/null @@ -1,326 +0,0 @@ -getForProperty(new ReflectionProperty(DataIterablePropertyFixture::class, 'array')); - $generic = $reader->getForProperty(new ReflectionProperty(DataIterablePropertyFixture::class, 'generic')); - $list = $reader->getForProperty(new ReflectionProperty(DataIterablePropertyFixture::class, 'list')); - $nullable = $reader->getForProperty(new ReflectionProperty(DataIterablePropertyFixture::class, 'nullable')); - $union = $reader->getForProperty(new ReflectionProperty(DataIterablePropertyFixture::class, 'union')); - - $this->assertAnnotation($array[0], 'array', 'FooData'); - $this->assertAnnotation($generic[0], 'array', 'FooData'); - $this->assertAnnotation($list[0], 'array', 'FooData'); - $this->assertAnnotation($nullable[0], 'Collection', 'FooData'); - $this->assertAnnotation($union[0], 'array', 'FooData'); - $this->assertAnnotation($union[1], 'Collection', 'BarData'); - $this->assertSame( - [DataIterablePropertyFixture::class], - array_values(array_unique(array_map( - fn (DataIterableAnnotation $annotation): string => $annotation->declaringClass, - [...$array, ...$generic, ...$list, ...$nullable, ...$union], - ))), - ); - $this->assertSame([], $reader->getForProperty( - new ReflectionProperty(DataIterablePropertyFixture::class, 'scalar'), - )); - } - - /** - * Test class property and method parameter annotations. - */ - public function testClassAndMethodAnnotationsAreKeyedByTheirDeclarationNames(): void - { - $reader = new DataIterableAnnotationReader; - $class = $reader->getForClass(new ReflectionClass(DataIterableClassFixture::class)); - $method = $reader->getForMethod(new ReflectionMethod(DataIterableClassFixture::class, 'handle')); - - $this->assertSame(['items'], array_keys($class)); - $this->assertSame('items', $class['items'][0]->property); - $this->assertSame(DataIterableClassFixture::class, $class['items'][0]->declaringClass); - $this->assertAnnotation($class['items'][0], 'array', 'FooData'); - - $this->assertSame(['values'], array_keys($method)); - $this->assertSame('values', $method['values'][0]->property); - $this->assertSame(DataIterableClassFixture::class, $method['values'][0]->declaringClass); - $this->assertAnnotation($method['values'][0], 'Collection', 'FooData'); - } - - /** - * Test native types screen only declarations that cannot use iterable metadata. - */ - public function testNativeTypesScreenIterableAnnotations(): void - { - $factory = $this->factory(new DataIterableAnnotationReader); - $method = new ReflectionMethod($factory, 'typeCanUseIterableAnnotation'); - $class = new ReflectionClass(DataIterableNativeTypeFixture::class); - $expected = [ - 'scalar' => false, - 'nullableScalar' => false, - 'enum' => false, - 'date' => false, - 'data' => false, - 'optional' => false, - 'lazy' => false, - 'array' => true, - 'iterable' => true, - 'mixed' => true, - 'object' => true, - 'custom' => true, - 'union' => true, - 'intersection' => true, - ]; - - foreach ($expected as $property => $canUseAnnotation) { - $this->assertSame( - $canUseAnnotation, - $method->invoke($factory, $class->getProperty($property)->getType()), - $property, - ); - } - - $this->assertTrue($method->invoke( - $factory, - $class->getMethod('acceptsCallable')->getParameters()[0]->getType(), - )); - } - - /** - * Test parser construction is lazy and preserves annotation precedence. - */ - public function testParserIsBuiltOnceForEligibleMetadata(): void - { - $reader = new DataIterableAnnotationReader; - $factory = $this->factory($reader); - - $factory->build(new ReflectionClass(DataIterableScalarOnlyData::class)); - - $this->assertNull($this->readerProperty($reader, 'lexer')); - $this->assertNull($this->readerProperty($reader, 'parser')); - - $class = $factory->build(new ReflectionClass(ChildAnnotations::class)); - $lexer = $this->readerProperty($reader, 'lexer'); - $parser = $this->readerProperty($reader, 'parser'); - - $this->assertInstanceOf(Lexer::class, $lexer); - $this->assertInstanceOf(PhpDocParser::class, $parser); - $this->assertSame(ParentClassItem::class, $this->iterableItemClass($class->properties['parentOnly'])); - $this->assertSame(ChildClassItem::class, $this->iterableItemClass($class->properties['classItems'])); - $this->assertSame(InlineItem::class, $this->iterableItemClass($class->properties['inlineItems'])); - $this->assertSame(ConstructorItem::class, $this->iterableItemClass($class->properties['constructorItems'])); - - $reader->getForProperty(new ReflectionProperty(DataIterablePropertyFixture::class, 'array')); - - $this->assertSame($lexer, $this->readerProperty($reader, 'lexer')); - $this->assertSame($parser, $this->readerProperty($reader, 'parser')); - } - - /** - * Assert one parsed iterable annotation. - */ - protected function assertAnnotation( - DataIterableAnnotation $annotation, - string $container, - string $item, - ): void { - $this->assertSame($container, $annotation->containerType); - $this->assertSame($item, (string) $annotation->itemType); - } - - /** - * Create the metadata factory with the given annotation reader. - */ - protected function factory(DataIterableAnnotationReader $reader): DataClassFactory - { - $defaults = require __DIR__ . '/../../../src/data/config/data.php'; - $config = new DataConfig(new Repository(['data' => $defaults])); - $nameMapperResolver = new NameMapperResolver(new Container); - $typeFactory = new DataTypeFactory(new PhpDocTypeNameResolver); - $parameterFactory = new DataParameterFactory($typeFactory); - - return new DataClassFactory( - new DataPropertyFactory($typeFactory, $config, $nameMapperResolver), - new DataMethodFactory($parameterFactory, $typeFactory), - $parameterFactory, - $reader, - $nameMapperResolver, - $config, - ); - } - - /** - * Get an internal parser dependency for verification. - */ - protected function readerProperty(DataIterableAnnotationReader $reader, string $name): ?object - { - return (new ReflectionProperty($reader, $name))->getValue($reader); - } - - /** - * Get the concrete iterable item class from property metadata. - */ - protected function iterableItemClass(DataProperty $property): string - { - return $property->type->getIterableTypes()[0]->iterableItemType?->getNamedTypes()[0]->name ?? ''; - } -} - -class DataIterablePropertyFixture -{ - /** @var FooData[] */ - public array $array; - - /** @var array */ - public array $generic; - - /** @var list */ - public array $list; - - /** @var null|Collection */ - public ?object $nullable; - - /** @var array|Collection */ - public array|object $union; - - /** @var FooData */ - public object $scalar; -} - -/** @property array $items */ -class DataIterableClassFixture -{ - public array $items; - - /** - * Handle the given values. - * - * @param Collection $values - */ - public function handle(object $values): void - { - } -} - -enum DataIterableScalarStatus: string -{ - case Ready = 'ready'; -} - -class DataIterableNestedData extends Data -{ - public function __construct(public int $id) - { - } -} - -class DataIterableCustomContainer -{ -} - -class DataIterableNativeTypeFixture -{ - public string $scalar; - - public ?int $nullableScalar; - - public DataIterableScalarStatus $enum; - - public DateTimeImmutable $date; - - public DataIterableNestedData $data; - - public Optional|int $optional; - - public Lazy|string $lazy; - - public array $array; - - public iterable $iterable; - - public mixed $mixed; - - public object $object; - - public DataIterableCustomContainer $custom; - - public array|string $union; - - public Countable&IteratorAggregate $intersection; - - public function acceptsCallable(callable $callback): void - { - } -} - -/** - * @property array $identifier - * @property array $name - */ -class DataIterableScalarOnlyData extends Data -{ - /** @var array */ - public string $name; - - /** - * @param array $identifier - * @param array $name - * @param array $optional - * @param array $lazy - * @param array $date - * @param array $status - * @param array $child - */ - public function __construct( - public int $identifier, - string $name, - public Optional|int $optional, - public Lazy|string $lazy, - public DateTimeImmutable $date, - public DataIterableScalarStatus $status, - public DataIterableNestedData $child, - ) { - $this->name = $name; - } -} diff --git a/tests/Data/Support/DataMethodTest.php b/tests/Data/Support/DataMethodTest.php index ba65256d35..7b09029ca0 100644 --- a/tests/Data/Support/DataMethodTest.php +++ b/tests/Data/Support/DataMethodTest.php @@ -7,54 +7,322 @@ use Attribute; use Hypervel\Container\Attributes\Config; use Hypervel\Container\Container; +use Hypervel\Data\Data; use Hypervel\Data\Enums\CustomCreationMethodType; use Hypervel\Data\Exceptions\InvalidDataDeclaration; +use Hypervel\Data\Support\Annotations\DataIterableAnnotationReader; use Hypervel\Data\Support\Creation\CreationContext; use Hypervel\Data\Support\DataMethod; use Hypervel\Data\Support\DataMethodMatch; +use Hypervel\Data\Support\DataParameter; use Hypervel\Data\Support\Factories\DataMethodFactory; use Hypervel\Data\Support\Factories\DataParameterFactory; use Hypervel\Data\Support\Factories\DataTypeFactory; +use Hypervel\Data\Support\Types\NamedType; use Hypervel\Data\Support\Types\PhpDocTypeNameResolver; +use Hypervel\Database\Eloquent\Collection as EloquentCollection; +use Hypervel\Support\Collection; +use Hypervel\Support\Enumerable; +use Hypervel\Tests\Data\Fixtures\DataWithMultipleArgumentCreationMethod; +use Hypervel\Tests\Data\Fixtures\SimpleData; use Hypervel\Tests\TestCase; use ReflectionClass; use ReflectionMethod; class DataMethodTest extends TestCase { - /** - * Test constructor and named creation metadata. - */ - public function testMethodMetadataPreservesDeclarationDetails(): void + // Spatie's accepts() is matchPayloads() returning a match, and its constructor method keeps only data properties + // and plain parameters, while Hypervel's keeps every parameter and the class's constructor inputs decide which + // receive input (DataClassTest). + + public function testCanCreateADataMethodFromAConstructor(): void { - $constructor = $this->method('__construct'); + $class = new class extends Data { + /** + * Create a data object with public promoted, protected promoted and plain parameters. + */ + public function __construct( + public string $promotedProperty = 'hello', + protected string $protectedPromotedProperty = 'hello', + string $property = 'hello', + ) { + } + }; + + $method = $this->method('__construct', $class::class); + + $this->assertSame('__construct', $method->name); + $this->assertSame( + ['promotedProperty' => true, 'protectedPromotedProperty' => true, 'property' => false], + array_column($method->parameters, 'isPromoted', 'name'), + ); + $this->assertTrue($method->isPublic); + $this->assertFalse($method->isStatic); + $this->assertSame(CustomCreationMethodType::None, $method->customCreationMethodType); + $this->assertNull($method->returnType); + } - $this->assertSame('__construct', $constructor->name); - $this->assertCount(2, $constructor->parameters); - $this->assertTrue($constructor->isPublic); - $this->assertFalse($constructor->isStatic); - $this->assertSame(CustomCreationMethodType::None, $constructor->customCreationMethodType); - $this->assertNull($constructor->returnType); - $this->assertTrue($constructor->parameters[0]->isPromoted); - $this->assertFalse($constructor->parameters[1]->isPromoted); + public function testCanCreateADataMethodFromAMagicMethod(): void + { + $class = new class extends Data { + /** + * Create the data object from a string. + */ + public static function fromString( + string $property, + ): self { + } + }; + + $method = $this->method('fromString', $class::class); + + $this->assertSame('fromString', $method->name); + $this->assertSame('fromString', $method->reflection->name); + $this->assertCount(1, $method->parameters); + $this->assertTrue($method->isPublic); + $this->assertTrue($method->isStatic); + $this->assertSame(CustomCreationMethodType::Object, $method->customCreationMethodType); + $this->assertInstanceOf(DataParameter::class, $method->parameters[0]); + $this->assertTrue($method->returns($class::class)); + } + + public function testCanCreateADataMethodFromAMagicCollectMethod(): void + { + $class = new class extends Data { + /** + * Collect items into an array. + */ + public static function collectArray( + array $items, + ): array { + } + }; + + $method = $this->method('collectArray', $class::class); + + $this->assertSame('collectArray', $method->name); + $this->assertCount(1, $method->parameters); + $this->assertTrue($method->isPublic); + $this->assertTrue($method->isStatic); + $this->assertSame(CustomCreationMethodType::Collection, $method->customCreationMethodType); + $this->assertInstanceOf(DataParameter::class, $method->parameters[0]); + $this->assertSame(['array'], $this->returnTypeNames($method)); + } + + public function testCanCreateADataMethodFromAMagicCollectMethodWithNullableReturnType(): void + { + $class = new class extends Data { + /** + * Collect items into an optional array. + */ + public static function collectArray( + array $items, + ): ?array { + } + }; - $from = $this->method('fromValues'); + $method = $this->method('collectArray', $class::class); - $this->assertSame(CustomCreationMethodType::Object, $from->customCreationMethodType); - $this->assertTrue($from->isStatic); - $this->assertTrue($from->returns(DataMethodFixture::class)); - $this->assertSame('fromValues', $from->reflection->name); + $this->assertSame(CustomCreationMethodType::Collection, $method->customCreationMethodType); + $this->assertSame(['array'], $this->returnTypeNames($method)); + $this->assertTrue($method->returnType?->isNullable); + } - $collect = $this->method('collectValues'); + public function testWillNotCreateAMagicalCollectionMethodWhenNoReturnTypeSpecified(): void + { + $class = new class extends Data { + /** + * Collect items without a declared return type. + */ + public static function collectArray( + array $items, + ) { + } + }; - $this->assertSame(CustomCreationMethodType::Collection, $collect->customCreationMethodType); - $this->assertTrue($collect->returnType?->isNullable); - $this->assertTrue($collect->returns('array')); + $method = $this->method('collectArray', $class::class); - $this->assertSame( - CustomCreationMethodType::None, - $this->method('collectUntyped')->customCreationMethodType, - ); + $this->assertSame(CustomCreationMethodType::None, $method->customCreationMethodType); + $this->assertNull($method->returnType); + } + + public function testWillReturnNullWhenAMethodDoesNotHaveAReturnType(): void + { + // From Spatie's DataReturnTypeTest; the method factory leaves the return type out when none is declared. + $class = new class { + /** + * Return a value without a declared type. + */ + public function none() + { + } + }; + + $this->assertNull($this->method('none', $class::class)->returnType); + } + + public function testCorrectlyAcceptsSingleValuesAsMagicCreationMethod(): void + { + $class = new class extends Data { + /** + * Create the data object from a string. + */ + public static function fromString( + string $property, + ) { + } + }; + + $method = $this->method('fromString', $class::class); + + $this->assertTrue($this->accepts($method, 'Hello')); + $this->assertFalse($this->accepts($method, 3.14)); + } + + public function testCorrectlyAcceptsSingleInheritedValuesAsMagicCreationMethod(): void + { + $class = new class extends Data { + /** + * Create the data object from another data object. + */ + public static function fromString( + Data $property, + ) { + } + }; + + $method = $this->method('fromString', $class::class); + + $this->assertTrue($this->accepts($method, new SimpleData('Hello'))); + } + + public function testCorrectlyAcceptsMultipleValuesAsMagicCreationMethod(): void + { + $method = $this->method('fromMultiple', DataWithMultipleArgumentCreationMethod::class); + + $this->assertTrue($this->accepts($method, 'Hello', 42)); + $this->assertTrue($this->accepts($method, ...[ + 'number' => 42, + 'string' => 'hello', + ])); + $this->assertFalse($this->accepts($method, 42, 'Hello')); + } + + public function testCorrectlyAcceptsMixedValuesAsMagicCreationMethod(): void + { + $class = new class extends Data { + /** + * Create the data object from any value. + */ + public static function fromString( + mixed $property, + ) { + } + }; + + $method = $this->method('fromString', $class::class); + + $this->assertTrue($this->accepts($method, new SimpleData('Hello'))); + $this->assertTrue($this->accepts($method, null)); + } + + public function testCorrectlyAcceptsValuesWithDefaultsAsMagicCreationMethod(): void + { + $class = new class extends Data { + /** + * Create the data object from an optional string. + */ + public static function fromString( + string $property = 'Hello', + ) { + } + }; + + $method = $this->method('fromString', $class::class); + + $this->assertTrue($this->accepts($method, 'Hello')); + $this->assertTrue($this->accepts($method)); + } + + public function testNeedsACorrectAmountOfParametersAsMagicCreationMethod(): void + { + $class = new class extends Data { + /** + * Create the data object from a required and an optional string. + */ + public static function fromString( + string $property, + string $propertyWithDefault = 'Hello', + ) { + } + }; + + $method = $this->method('fromString', $class::class); + + $this->assertTrue($this->accepts($method, 'Hello')); + $this->assertTrue($this->accepts($method, 'Hello', 'World')); + $this->assertFalse($this->accepts($method)); + $this->assertFalse($this->accepts($method, 'Hello', 'World', 'Nope')); + } + + public function testCanCheckIfAMagicalMethodCanReturnTheExactType(): void + { + $class = new class extends Data { + /** + * Collect a collection. + */ + public static function collectCollection( + Collection $property, + ): Collection { + } + }; + + $this->assertTrue($this->method('collectCollection', $class::class)->returns(Collection::class)); + } + + public function testCanCheckIfAMagicalMethodCanReturnTheSubType(): void + { + $class = new class extends Data { + /** + * Collect a collection. + */ + public static function collectCollection( + Collection $property, + ): Collection { + } + }; + + $this->assertTrue($this->method('collectCollection', $class::class)->returns(EloquentCollection::class)); + } + + public function testCanCheckIfAMagicalMethodCanReturnABuiltInType(): void + { + $class = new class extends Data { + /** + * Collect a collection into an array. + */ + public static function collectCollectionToArray( + Collection $property, + ): array { + } + }; + + $this->assertTrue($this->method('collectCollectionToArray', $class::class)->returns('array')); + } + + public function testCanCheckIfAMagicalMethodCannotReturnAParentType(): void + { + $class = new class extends Data { + /** + * Collect a collection. + */ + public static function collectCollection( + Collection $property, + ): Collection { + } + }; + + $this->assertFalse($this->method('collectCollection', $class::class)->returns(Enumerable::class)); } /** @@ -335,7 +603,7 @@ public function testVariadicCreationContextIsRejectedDuringMetadataBuild(): void protected function method(string $name, string $className = DataMethodFixture::class): DataMethod { $class = new ReflectionClass($className); - $typeFactory = new DataTypeFactory(new PhpDocTypeNameResolver); + $typeFactory = new DataTypeFactory(new PhpDocTypeNameResolver, new DataIterableAnnotationReader); $factory = new DataMethodFactory( new DataParameterFactory($typeFactory), $typeFactory, @@ -352,6 +620,27 @@ protected function context(): CreationContext return new CreationContext(DataMethodFixture::class); } + /** + * Determine if a method can be called with the given payloads. + */ + protected function accepts(DataMethod $method, mixed ...$payloads): bool + { + return $method->matchPayloads($this->context(), ...$payloads) !== null; + } + + /** + * Get the type names a method's return type declares. + * + * @return list + */ + protected function returnTypeNames(DataMethod $method): array + { + return array_map( + fn (NamedType $type): string => $type->name, + $method->returnType?->getNamedTypes() ?? [], + ); + } + /** * Invoke a matched fixture method through its selected path. */ @@ -520,24 +809,6 @@ public static function fromVariadic(string ...$values): self { return new self($values[0] ?? 'default'); } - - /** - * Collect fixture values. - * - * @return null|array - */ - public static function collectValues(array $values): ?array - { - return $values; - } - - /** - * Collect fixture values without a declared return type. - */ - public static function collectUntyped(array $values) - { - return $values; - } } interface DataMethodFirstContract diff --git a/tests/Data/Support/DataParameterTest.php b/tests/Data/Support/DataParameterTest.php index 8f1d2ea2ca..d3d860b314 100644 --- a/tests/Data/Support/DataParameterTest.php +++ b/tests/Data/Support/DataParameterTest.php @@ -5,22 +5,98 @@ namespace Hypervel\Tests\Data\Support; use Hypervel\Container\Attributes\Config; +use Hypervel\Data\Data; +use Hypervel\Data\Support\Annotations\DataIterableAnnotationReader; +use Hypervel\Data\Support\Creation\CreationContext; +use Hypervel\Data\Support\DataParameter; use Hypervel\Data\Support\Factories\DataParameterFactory; use Hypervel\Data\Support\Factories\DataTypeFactory; use Hypervel\Data\Support\Types\PhpDocTypeNameResolver; +use Hypervel\Tests\Data\Fixtures\SimpleData; use Hypervel\Tests\TestCase; use ReflectionClass; use ReflectionParameter; class DataParameterTest extends TestCase { + public function testCanCreateADataParameter(): void + { + $class = new class('', '', '', new CreationContext(SimpleData::class)) extends Data { + /** + * Create a data object with plain, untyped, promoted, creation context and defaulted parameters. + * @param mixed $withoutType + */ + public function __construct( + string $nonPromoted, + public $withoutType, + public string $property, + CreationContext $creationContext, + public string $propertyWithDefault = 'hello', + ) { + } + }; + $factory = new DataParameterFactory(new DataTypeFactory(new PhpDocTypeNameResolver, new DataIterableAnnotationReader)); + $parameter = fn (string $name): DataParameter => $factory->build( + new ReflectionParameter([$class::class, '__construct'], $name), + new ReflectionClass($class), + ); + + // A creation context is recognized by its class name rather than Spatie's isCreationContext(). + $nonPromoted = $parameter('nonPromoted'); + + $this->assertSame('nonPromoted', $nonPromoted->name); + $this->assertFalse($nonPromoted->isPromoted); + $this->assertFalse($nonPromoted->hasDefaultValue); + $this->assertSame('string', $nonPromoted->type->getNamedTypes()[0]->name); + $this->assertFalse($nonPromoted->type->isNullable); + $this->assertNull($nonPromoted->className); + + $withoutType = $parameter('withoutType'); + + $this->assertSame('withoutType', $withoutType->name); + $this->assertTrue($withoutType->isPromoted); + $this->assertFalse($withoutType->hasDefaultValue); + $this->assertTrue($withoutType->type->isMixed); + $this->assertTrue($withoutType->type->isNullable); + $this->assertNull($withoutType->className); + + $property = $parameter('property'); + + $this->assertSame('property', $property->name); + $this->assertTrue($property->isPromoted); + $this->assertFalse($property->hasDefaultValue); + $this->assertSame('string', $property->type->getNamedTypes()[0]->name); + $this->assertFalse($property->type->isNullable); + $this->assertNull($property->className); + + $creationContext = $parameter('creationContext'); + + $this->assertSame('creationContext', $creationContext->name); + $this->assertFalse($creationContext->isPromoted); + $this->assertFalse($creationContext->hasDefaultValue); + $this->assertSame(CreationContext::class, $creationContext->type->getNamedTypes()[0]->name); + $this->assertFalse($creationContext->type->isNullable); + $this->assertSame(CreationContext::class, $creationContext->className); + + $propertyWithDefault = $parameter('propertyWithDefault'); + + // Defaults are read from reflection when needed, so the metadata keeps none (README). + $this->assertSame('propertyWithDefault', $propertyWithDefault->name); + $this->assertTrue($propertyWithDefault->isPromoted); + $this->assertTrue($propertyWithDefault->hasDefaultValue); + $this->assertSame('hello', $propertyWithDefault->reflection->getDefaultValue()); + $this->assertSame('string', $propertyWithDefault->type->getNamedTypes()[0]->name); + $this->assertFalse($propertyWithDefault->type->isNullable); + $this->assertNull($propertyWithDefault->className); + } + /** * Test immutable parameter metadata without retaining default values. */ public function testParameterMetadataPreservesConstructionRecipes(): void { $class = new ReflectionClass(DataParameterFixture::class); - $factory = new DataParameterFactory(new DataTypeFactory(new PhpDocTypeNameResolver)); + $factory = new DataParameterFactory(new DataTypeFactory(new PhpDocTypeNameResolver, new DataIterableAnnotationReader)); $plainReflection = new ReflectionParameter([DataParameterFixture::class, '__construct'], 'plain'); $plain = $factory->build($plainReflection, $class); diff --git a/tests/Data/Support/DataPropertyTest.php b/tests/Data/Support/DataPropertyTest.php index 3cbe4096d4..5ef11ccee6 100644 --- a/tests/Data/Support/DataPropertyTest.php +++ b/tests/Data/Support/DataPropertyTest.php @@ -6,7 +6,11 @@ use Hypervel\Config\Repository; use Hypervel\Container\Container; +use Hypervel\Contracts\Foundation\Application; +use Hypervel\Data\Attributes\AutoClosureLazy; +use Hypervel\Data\Attributes\AutoInertiaLazy; use Hypervel\Data\Attributes\AutoLazy; +use Hypervel\Data\Attributes\AutoWhenLoadedLazy; use Hypervel\Data\Attributes\Computed; use Hypervel\Data\Attributes\Hidden; use Hypervel\Data\Attributes\LoadRelation; @@ -15,13 +19,18 @@ use Hypervel\Data\Attributes\MapOutputName; use Hypervel\Data\Attributes\PropertyForMorph; use Hypervel\Data\Attributes\WithCast; +use Hypervel\Data\Attributes\WithCastAndTransformer; use Hypervel\Data\Attributes\WithoutValidation; use Hypervel\Data\Attributes\WithTransformer; use Hypervel\Data\Casts\Cast; +use Hypervel\Data\Casts\DateTimeInterfaceCast; +use Hypervel\Data\Data; +use Hypervel\Data\DataServiceProvider; +use Hypervel\Data\Lazy; use Hypervel\Data\Mappers\KebabCaseMapper; use Hypervel\Data\Mappers\SnakeCaseMapper; use Hypervel\Data\Optional; -use Hypervel\Data\Support\Creation\ConstructionState; +use Hypervel\Data\Support\Annotations\DataIterableAnnotationReader; use Hypervel\Data\Support\Creation\CreationContext; use Hypervel\Data\Support\DataConfig; use Hypervel\Data\Support\DataProperty; @@ -32,14 +41,240 @@ use Hypervel\Data\Support\NameMapperResolver; use Hypervel\Data\Support\Transformation\TransformationContext; use Hypervel\Data\Support\Types\PhpDocTypeNameResolver; +use Hypervel\Data\Transformers\DateTimeInterfaceTransformer; use Hypervel\Data\Transformers\Transformer; use Hypervel\Database\Eloquent\Model; -use Hypervel\Tests\TestCase; +use Hypervel\Testbench\TestCase; +use Hypervel\Tests\Data\Fixtures\Models\DummyModel; +use Hypervel\Tests\Data\Fixtures\SimpleData; +use Hypervel\Tests\Data\Fixtures\SimpleDataWithPropertyHooks; use ReflectionAttribute; use ReflectionClass; class DataPropertyTest extends TestCase { + /** + * Get package providers for the test application. + */ + protected function getPackageProviders(Application $app): array + { + return [DataServiceProvider::class]; + } + + public function testCanGetTheCastAttributeWithArguments(): void + { + $helper = $this->resolveHelper(new class { + #[WithCast(DateTimeInterfaceCast::class, 'd-m-y')] + public SimpleData $property; + }); + + $this->assertEquals(new DateTimeInterfaceCast('d-m-y'), $helper->cast?->newInstance()->get()); + } + + public function testCanGetTheTransformerAttribute(): void + { + $helper = $this->resolveHelper(new class { + #[WithTransformer(DateTimeInterfaceTransformer::class)] + public SimpleData $property; + }); + + $this->assertEquals(new DateTimeInterfaceTransformer, $helper->transformer?->newInstance()->get()); + } + + public function testCanGetTheTransformerAttributeWithArguments(): void + { + $helper = $this->resolveHelper(new class { + #[WithTransformer(DateTimeInterfaceTransformer::class, 'd-m-y')] + public SimpleData $property; + }); + + $this->assertEquals(new DateTimeInterfaceTransformer('d-m-y'), $helper->transformer?->newInstance()->get()); + } + + public function testCanGetTheCastWithTransformerAttribute(): void + { + $helper = $this->resolveHelper(new class { + #[WithCastAndTransformer(FakeCastTransformer::class)] + public SimpleData $property; + }); + + $this->assertEquals(new FakeCastTransformer, $helper->transformer?->newInstance()->get()); + $this->assertEquals(new FakeCastTransformer, $helper->cast?->newInstance()->get()); + } + + public function testCanGetTheMappedInputName(): void + { + $helper = $this->resolveHelper(new class { + #[MapInputName('other')] + public SimpleData $property; + }); + + $this->assertEquals('other', $helper->inputMappedName); + } + + public function testCanGetTheMappedOutputName(): void + { + $helper = $this->resolveHelper(new class { + #[MapOutputName('other')] + public SimpleData $property; + }); + + $this->assertEquals('other', $helper->outputMappedName); + } + + public function testCanGetTheDefaultValue(): void + { + $helper = $this->resolveHelper(new class { + public string $property; + }); + + $this->assertFalse($helper->hasDefaultValue); + + $helper = $this->resolveHelper(new class { + public string $property = 'hello'; + }); + + // Metadata records only whether a default exists; the value itself is read when needed. + $this->assertTrue($helper->hasDefaultValue); + } + + public function testWillIgnoreAnOptionalValueAsADefaultValue(): void + { + $helper = $this->resolveHelper(new class { + /** + * Create the object with an Optional default. + */ + public function __construct( + public string|Optional $property = new Optional, + ) { + } + }); + + $this->assertFalse($helper->hasDefaultValue); + } + + public function testCanCheckIfThePropertyIsPromoted(): void + { + $helper = $this->resolveHelper(new class('') { + /** + * Create the object with a promoted property. + */ + public function __construct( + public string $property, + ) { + } + }); + + $this->assertTrue($helper->isPromoted); + + $helper = $this->resolveHelper(new class { + public string $property; + }); + + $this->assertFalse($helper->isPromoted); + } + + public function testCanCheckIfAPropertyShouldBeValidated(): void + { + $this->assertTrue($this->resolveHelper(new class { + public string $property; + })->validate); + + $this->assertFalse($this->resolveHelper(new class { + #[WithoutValidation] + public string $property; + })->validate); + + $this->assertFalse($this->resolveHelper(new class { + #[Computed] + public string $property; + })->validate); + } + + public function testCanCheckIfAPropertyIsComputed(): void + { + $this->assertFalse($this->resolveHelper(new class { + public string $property; + })->computed); + + $this->assertTrue($this->resolveHelper(new class { + #[Computed] + public string $property; + })->computed); + } + + public function testCanCheckIfAVirtualPropertyIsComputed(): void + { + $this->assertTrue($this->resolveHelper(new SimpleDataWithPropertyHooks, propertyName: 'virtual')->computed); + } + + public function testDoesNotMarkABackedPropertyAsComputed(): void + { + $this->assertFalse($this->resolveHelper(new SimpleDataWithPropertyHooks, propertyName: 'backed')->computed); + } + + public function testCanCheckIfAPropertyIsHidden(): void + { + $this->assertFalse($this->resolveHelper(new class { + public string $property; + })->hidden); + + $this->assertTrue($this->resolveHelper(new class { + #[Hidden] + public string $property; + })->hidden); + } + + public function testCanCheckIfAPropertyIsAutoLazy(): void + { + $this->assertNull($this->resolveHelper(new class { + public string $property; + })->autoLazy); + + $this->assertInstanceOf(AutoLazy::class, $this->resolveHelper(new class { + #[AutoLazy] + public string $property; + })->autoLazy?->newInstance()); + + $this->assertInstanceOf(AutoInertiaLazy::class, $this->resolveHelper(new class { + #[AutoInertiaLazy] + public string|Lazy $property; + })->autoLazy?->newInstance()); + + $this->assertInstanceOf(AutoWhenLoadedLazy::class, $this->resolveHelper(new class { + #[AutoWhenLoadedLazy('relation')] + public string|Lazy $property; + })->autoLazy?->newInstance()); + + $this->assertInstanceOf(AutoClosureLazy::class, $this->resolveHelper(new class { + #[AutoClosureLazy] + public string $property; + })->autoLazy?->newInstance()); + } + + public function testWillSetAPropertyAsAutoLazyWhenTheClassIsAutoLazyAndALazyTypeIsAllowed(): void + { + $classAutoLazy = (new ReflectionClass(AutoLazyPropertyClass::class))->getAttributes(AutoLazy::class)[0]; + + $this->assertNull($this->resolveHelper(new class { + public string $property; + }, $classAutoLazy)->autoLazy); + + $this->assertInstanceOf(AutoLazy::class, $this->resolveHelper(new class { + public string|Lazy $property; + }, $classAutoLazy)->autoLazy?->newInstance()); + } + + public function testWontThrowAnErrorIfNonExistingAttributeIsUsedOnADataClassProperty(): void + { + $this->assertEquals('hello', NonExistingPropertyAttributeData::from(['property' => 'hello'])->property); + $this->assertEquals('hello', PhpStormAttributeData::from(['property' => 'hello'])->property); + $this->assertEquals('hello', PhpStormAttributeData::from('{"property": "hello"}')->property); + $this->assertEquals('hello', PhpStormAttributeData::from((object) ['property' => 'hello'])->property); + $this->assertEquals(1, ModelWithPhpStormAttributePropertyData::from((new DummyModel)->fill(['id' => 1]))->id); + $this->assertEquals(1, ModelWithPromotedPhpStormAttributePropertyData::from((new DummyModel)->fill(['id' => 1]))->id); + } + /** * Test property flags and extension recipes. */ @@ -89,7 +324,6 @@ public function testDefaultsComputedAndVirtualPropertiesAreCompiled(): void { [$factory, $config, $mapperResolver] = $this->factory(); $class = new ReflectionClass(DataPropertyFixture::class); - $optional = $this->buildProperty($factory, $class, 'optional', $config, $mapperResolver); $nonPromoted = $this->buildProperty( $factory, $class, @@ -101,8 +335,6 @@ public function testDefaultsComputedAndVirtualPropertiesAreCompiled(): void $virtual = $this->buildProperty($factory, $class, 'virtual', $config, $mapperResolver); $backedHook = $this->buildProperty($factory, $class, 'backedHook', $config, $mapperResolver); - $this->assertFalse($optional->hasDefaultValue); - $this->assertTrue($optional->type->isOptional); $this->assertFalse($nonPromoted->isPromoted); $this->assertFalse($nonPromoted->isConstructorParameter); $this->assertTrue($nonPromoted->hasDefaultValue); @@ -191,6 +423,36 @@ public function testResolvesOnlyMarkedEloquentRelations(): void $this->assertNull($relation->resolveModelRelation($model)); } + /** + * Build one property of an object's class with the default configuration. + * + * @param null|ReflectionAttribute $classAutoLazy + */ + protected function resolveHelper( + object $class, + ?ReflectionAttribute $classAutoLazy = null, + string $propertyName = 'property', + ): DataProperty { + [$factory] = $this->factory(); + $reflectionClass = new ReflectionClass($class); + $constructorParameter = null; + + foreach ($reflectionClass->getConstructor()?->getParameters() ?? [] as $parameter) { + if ($parameter->name === $propertyName) { + $constructorParameter = (new DataParameterFactory( + new DataTypeFactory(new PhpDocTypeNameResolver, new DataIterableAnnotationReader), + ))->build($parameter, $reflectionClass); + } + } + + return $factory->build( + reflectionProperty: $reflectionClass->getProperty($propertyName), + reflectionClass: $reflectionClass, + constructorParameter: $constructorParameter, + classAutoLazy: $classAutoLazy, + ); + } + /** * Build one fixture property with its constructor default metadata. * @@ -209,7 +471,7 @@ protected function buildProperty( foreach ($class->getConstructor()?->getParameters() ?? [] as $parameter) { if ($parameter->name === $name) { $constructorParameter = (new DataParameterFactory( - new DataTypeFactory(new PhpDocTypeNameResolver), + new DataTypeFactory(new PhpDocTypeNameResolver, new DataIterableAnnotationReader), ))->build($parameter, $class); break; @@ -241,7 +503,7 @@ protected function factory(array $overrides = []): array 'data' => array_replace($defaults, $overrides), ])); $mapperResolver = new NameMapperResolver(new Container); - $typeFactory = new DataTypeFactory(new PhpDocTypeNameResolver); + $typeFactory = new DataTypeFactory(new PhpDocTypeNameResolver, new DataIterableAnnotationReader); return [ new DataPropertyFactory($typeFactory, $config, $mapperResolver), @@ -274,7 +536,6 @@ public function __construct( #[WithTransformer(PropertyTransformer::class)] #[WithoutValidation] public readonly string $displayName = 'Taylor', - public string|Optional $optional = new Optional, string $constructorDefault = 'constructor-default', ) { $this->readonlyBound = $readonlyBound; @@ -336,7 +597,7 @@ class PropertyCast implements Cast public function cast( DataProperty $property, mixed $value, - ConstructionState $state, + array $properties, CreationContext $context, ): mixed { return $value; @@ -364,3 +625,97 @@ public function transform( class PropertyFallbackTransformer extends PropertyTransformer { } + +class FakeCastTransformer implements Cast, Transformer +{ + /** + * Cast a property value. + */ + public function cast(DataProperty $property, mixed $value, array $properties, CreationContext $context): mixed + { + return $value; + } + + /** + * Transform a property value. + */ + public function transform(DataProperty $property, mixed $value, TransformationContext $context): mixed + { + return $value; + } +} + +#[AutoLazy] +class AutoLazyPropertyClass +{ +} + +class NonExistingPropertyAttributeData extends Data +{ + #[\Foo\Bar] + public readonly string $property; + + /** + * Create a fixture whose property uses an undefined attribute. + */ + public function __construct(string $property) + { + $this->property = $property; + } +} + +class PhpStormAttributeData extends Data +{ + #[\JetBrains\PhpStorm\Immutable] + public readonly string $property; + + /** + * Create a fixture whose property uses an IDE attribute. + */ + public function __construct(string $property) + { + $this->property = $property; + } +} + +class ModelWithPhpStormAttributePropertyData extends Data +{ + #[\JetBrains\PhpStorm\Immutable] + public int $id; + + /** + * Create a model fixture whose property uses an IDE attribute. + */ + public function __construct(int $id) + { + $this->id = $id; + } + + /** + * Create the fixture from a dummy model. + */ + public static function fromDummyModel(DummyModel $model): self + { + return new self($model->id); + } +} + +class ModelWithPromotedPhpStormAttributePropertyData extends Data +{ + /** + * Create a model fixture whose promoted property uses an IDE attribute. + */ + public function __construct( + #[\JetBrains\PhpStorm\Immutable] + public int $id + ) { + } + + /** + * Create the fixture from a dummy model. + */ + public static function fromDummyModel(DummyModel $model): self + { + return new self($model->id); + } +} diff --git a/tests/Data/Support/DataPropertyTypeTest.php b/tests/Data/Support/DataPropertyTypeTest.php new file mode 100644 index 0000000000..13716c679f --- /dev/null +++ b/tests/Data/Support/DataPropertyTypeTest.php @@ -0,0 +1,1145 @@ +resolveDataType(new class { + public $property; + }); + + $this->assertFalse($type->isOptional); + $this->assertTrue($type->isNullable); + $this->assertTrue($type->isMixed); + $this->assertNull($type->lazyType); + $this->assertValueType($type, 'mixed', true, DataTypeKind::Default); + } + + public function testCanDeduceATypeWithDefinition(): void + { + $type = $this->resolveDataType(new class { + public string $property; + }); + + $this->assertFalse($type->isOptional); + $this->assertFalse($type->isNullable); + $this->assertFalse($type->isMixed); + $this->assertNull($type->lazyType); + $this->assertValueType($type, 'string', true, DataTypeKind::Default); + } + + public function testCanDeduceANullableTypeWithDefinition(): void + { + $type = $this->resolveDataType(new class { + public ?string $property; + }); + + $this->assertFalse($type->isOptional); + $this->assertTrue($type->isNullable); + $this->assertFalse($type->isMixed); + $this->assertNull($type->lazyType); + $this->assertValueType($type, 'string', true, DataTypeKind::Default); + } + + public function testCanDeduceAUnionTypeDefinition(): void + { + $type = $this->resolveDataType(new class { + public string|int $property; + }); + + $this->assertFalse($type->isOptional); + $this->assertFalse($type->isNullable); + $this->assertFalse($type->isMixed); + $this->assertNull($type->lazyType); + $this->assertSame(['string', 'int'], $this->valueTypeNames($type)); + $this->assertInstanceOf(UnionType::class, $type->type); + } + + public function testCanDeduceANullableUnionTypeDefinition(): void + { + $type = $this->resolveDataType(new class { + public string|int|null $property; + }); + + $this->assertFalse($type->isOptional); + $this->assertTrue($type->isNullable); + $this->assertFalse($type->isMixed); + $this->assertNull($type->lazyType); + $this->assertSame(['string', 'int'], $this->valueTypeNames($type)); + $this->assertInstanceOf(UnionType::class, $type->type); + } + + public function testCanDeduceAnIntersectionTypeDefinition(): void + { + $type = $this->resolveDataType(new class { + public DateTime&DateTimeImmutable $property; + }); + + $this->assertFalse($type->isOptional); + $this->assertFalse($type->isNullable); + $this->assertFalse($type->isMixed); + $this->assertNull($type->lazyType); + $this->assertSame([DateTime::class, DateTimeImmutable::class], $this->valueTypeNames($type)); + $this->assertInstanceOf(IntersectionType::class, $type->type); + } + + public function testCanDeduceANullableIntersectionTypeDefinition(): void + { + $type = $this->resolveDataType(new class { + public (DateTime&DateTimeImmutable)|null $property; + }); + + // The declared union keeps null beside the intersection, where Spatie's type is the intersection alone. + $this->assertFalse($type->isOptional); + $this->assertTrue($type->isNullable); + $this->assertFalse($type->isMixed); + $this->assertNull($type->lazyType); + $this->assertSame([DateTime::class, DateTimeImmutable::class], $this->valueTypeNames($type)); + $this->assertInstanceOf(UnionType::class, $type->type); + $this->assertInstanceOf(IntersectionType::class, $type->type->types[0]); + } + + public function testCanDeduceAMixedType(): void + { + $type = $this->resolveDataType(new class { + public mixed $property; + }); + + $this->assertFalse($type->isOptional); + $this->assertTrue($type->isNullable); + $this->assertTrue($type->isMixed); + $this->assertNull($type->lazyType); + $this->assertValueType($type, 'mixed', true, DataTypeKind::Default); + } + + public function testCanDeduceALazyType(): void + { + $type = $this->resolveDataType(new class { + public string|Lazy $property; + }); + + $this->assertFalse($type->isOptional); + $this->assertFalse($type->isNullable); + $this->assertFalse($type->isMixed); + $this->assertSame(Lazy::class, $type->lazyType); + $this->assertValueType($type, 'string', true, DataTypeKind::Default); + } + + public function testCanDeduceAnOptionalType(): void + { + $type = $this->resolveDataType(new class { + public string|Optional $property; + }); + + $this->assertTrue($type->isOptional); + $this->assertFalse($type->isNullable); + $this->assertFalse($type->isMixed); + $this->assertNull($type->lazyType); + $this->assertValueType($type, 'string', true, DataTypeKind::Default); + } + + public function testCanDeduceADataType(): void + { + $type = $this->resolveDataType(new class { + public SimpleData $property; + }); + + $this->assertFalse($type->isOptional); + $this->assertFalse($type->isNullable); + $this->assertFalse($type->isMixed); + $this->assertNull($type->lazyType); + $this->assertValueType($type, SimpleData::class, false, DataTypeKind::DataObject, SimpleData::class); + } + + public function testCanDeduceADataUnionType(): void + { + $type = $this->resolveDataType(new class { + public SimpleData|Lazy $property; + }); + + $this->assertFalse($type->isOptional); + $this->assertFalse($type->isNullable); + $this->assertFalse($type->isMixed); + $this->assertSame(Lazy::class, $type->lazyType); + $this->assertValueType($type, SimpleData::class, false, DataTypeKind::DataObject, SimpleData::class); + } + + public function testCanDeduceADataCollectionType(): void + { + $type = $this->resolveDataType(new class { + #[DataCollectionOf(SimpleData::class)] + public DataCollection $property; + }); + + $this->assertFalse($type->isOptional); + $this->assertFalse($type->isNullable); + $this->assertFalse($type->isMixed); + $this->assertNull($type->lazyType); + $this->assertValueType($type, DataCollection::class, false, DataTypeKind::DataCollection, SimpleData::class, DataCollection::class); + } + + public function testCanDeduceADataCollectionUnionType(): void + { + $type = $this->resolveDataType(new class { + #[DataCollectionOf(SimpleData::class)] + public DataCollection|Lazy $property; + }); + + $this->assertFalse($type->isOptional); + $this->assertFalse($type->isNullable); + $this->assertFalse($type->isMixed); + $this->assertSame(Lazy::class, $type->lazyType); + $this->assertValueType($type, DataCollection::class, false, DataTypeKind::DataCollection, SimpleData::class, DataCollection::class); + } + + public function testCanDeduceAPaginatedDataCollectionType(): void + { + $type = $this->resolveDataType(new class { + #[DataCollectionOf(SimpleData::class)] + public PaginatedDataCollection $property; + }); + + $this->assertFalse($type->isOptional); + $this->assertFalse($type->isNullable); + $this->assertFalse($type->isMixed); + $this->assertNull($type->lazyType); + $this->assertValueType($type, PaginatedDataCollection::class, false, DataTypeKind::DataPaginatedCollection, SimpleData::class, PaginatedDataCollection::class); + } + + public function testCanDeduceAPaginatedDataCollectionUnionType(): void + { + $type = $this->resolveDataType(new class { + #[DataCollectionOf(SimpleData::class)] + public PaginatedDataCollection|Lazy $property; + }); + + $this->assertFalse($type->isOptional); + $this->assertFalse($type->isNullable); + $this->assertFalse($type->isMixed); + $this->assertSame(Lazy::class, $type->lazyType); + $this->assertValueType($type, PaginatedDataCollection::class, false, DataTypeKind::DataPaginatedCollection, SimpleData::class, PaginatedDataCollection::class); + } + + public function testCanDeduceACursorPaginatedDataCollectionType(): void + { + $type = $this->resolveDataType(new class { + #[DataCollectionOf(SimpleData::class)] + public CursorPaginatedDataCollection $property; + }); + + $this->assertFalse($type->isOptional); + $this->assertFalse($type->isNullable); + $this->assertFalse($type->isMixed); + $this->assertNull($type->lazyType); + $this->assertValueType($type, CursorPaginatedDataCollection::class, false, DataTypeKind::DataCursorPaginatedCollection, SimpleData::class, CursorPaginatedDataCollection::class); + } + + public function testCanDeduceACursorPaginatedDataCollectionUnionType(): void + { + $type = $this->resolveDataType(new class { + #[DataCollectionOf(SimpleData::class)] + public CursorPaginatedDataCollection|Lazy $property; + }); + + $this->assertFalse($type->isOptional); + $this->assertFalse($type->isNullable); + $this->assertFalse($type->isMixed); + $this->assertSame(Lazy::class, $type->lazyType); + $this->assertValueType($type, CursorPaginatedDataCollection::class, false, DataTypeKind::DataCursorPaginatedCollection, SimpleData::class, CursorPaginatedDataCollection::class); + } + + public function testCanDeduceAnArrayDataCollectionType(): void + { + $type = $this->resolveDataType(new class { + #[DataCollectionOf(SimpleData::class)] + public array $property; + }); + + $this->assertFalse($type->isOptional); + $this->assertFalse($type->isNullable); + $this->assertFalse($type->isMixed); + $this->assertNull($type->lazyType); + $this->assertValueType($type, 'array', true, DataTypeKind::DataArray, SimpleData::class, 'array'); + } + + public function testCanDeduceAnArrayDataCollectionUnionType(): void + { + $type = $this->resolveDataType(new class { + #[DataCollectionOf(SimpleData::class)] + public array|Lazy $property; + }); + + $this->assertFalse($type->isOptional); + $this->assertFalse($type->isNullable); + $this->assertFalse($type->isMixed); + $this->assertSame(Lazy::class, $type->lazyType); + $this->assertValueType($type, 'array', true, DataTypeKind::DataArray, SimpleData::class, 'array'); + } + + public function testCanDeduceAnEnumerableDataCollectionType(): void + { + $type = $this->resolveDataType(new class { + #[DataCollectionOf(SimpleData::class)] + public Collection $property; + }); + + $this->assertFalse($type->isOptional); + $this->assertFalse($type->isNullable); + $this->assertFalse($type->isMixed); + $this->assertNull($type->lazyType); + $this->assertValueType($type, Collection::class, false, DataTypeKind::DataEnumerable, SimpleData::class, Collection::class); + } + + public function testCanDeduceAnEnumerableDataCollectionUnionType(): void + { + $type = $this->resolveDataType(new class { + #[DataCollectionOf(SimpleData::class)] + public Collection|Lazy $property; + }); + + $this->assertFalse($type->isOptional); + $this->assertFalse($type->isNullable); + $this->assertFalse($type->isMixed); + $this->assertSame(Lazy::class, $type->lazyType); + $this->assertValueType($type, Collection::class, false, DataTypeKind::DataEnumerable, SimpleData::class, Collection::class); + } + + public function testCanDeduceAnEnumerableDataCollectionTypeFromCollection(): void + { + $type = $this->resolveDataType(new class { + public SimpleDataCollectionWithAnotations $property; + }); + + $this->assertFalse($type->isOptional); + $this->assertFalse($type->isNullable); + $this->assertFalse($type->isMixed); + $this->assertNull($type->lazyType); + $this->assertValueType($type, SimpleDataCollectionWithAnotations::class, false, DataTypeKind::DataEnumerable, SimpleData::class, SimpleDataCollectionWithAnotations::class); + } + + public function testCanDeduceAnEnumerableDataCollectionUnionTypeFromCollection(): void + { + $type = $this->resolveDataType(new class { + public SimpleDataCollectionWithAnotations|Lazy $property; + }); + + $this->assertFalse($type->isOptional); + $this->assertFalse($type->isNullable); + $this->assertFalse($type->isMixed); + $this->assertSame(Lazy::class, $type->lazyType); + $this->assertValueType($type, SimpleDataCollectionWithAnotations::class, false, DataTypeKind::DataEnumerable, SimpleData::class, SimpleDataCollectionWithAnotations::class); + } + + public function testCanDeduceAPaginatorDataCollectionType(): void + { + $type = $this->resolveDataType(new class { + #[DataCollectionOf(SimpleData::class)] + public LengthAwarePaginator $property; + }); + + $this->assertFalse($type->isOptional); + $this->assertFalse($type->isNullable); + $this->assertFalse($type->isMixed); + $this->assertNull($type->lazyType); + $this->assertValueType($type, LengthAwarePaginator::class, false, DataTypeKind::DataPaginator, SimpleData::class, LengthAwarePaginator::class); + } + + public function testCanDeduceAPaginatorDataCollectionUnionType(): void + { + $type = $this->resolveDataType(new class { + #[DataCollectionOf(SimpleData::class)] + public LengthAwarePaginator|Lazy $property; + }); + + $this->assertFalse($type->isOptional); + $this->assertFalse($type->isNullable); + $this->assertFalse($type->isMixed); + $this->assertSame(Lazy::class, $type->lazyType); + $this->assertValueType($type, LengthAwarePaginator::class, false, DataTypeKind::DataPaginator, SimpleData::class, LengthAwarePaginator::class); + } + + public function testCanDeduceACursorPaginatorDataCollectionType(): void + { + $type = $this->resolveDataType(new class { + #[DataCollectionOf(SimpleData::class)] + public CursorPaginator $property; + }); + + $this->assertFalse($type->isOptional); + $this->assertFalse($type->isNullable); + $this->assertFalse($type->isMixed); + $this->assertNull($type->lazyType); + $this->assertValueType($type, CursorPaginator::class, false, DataTypeKind::DataCursorPaginator, SimpleData::class, CursorPaginator::class); + } + + public function testCanDeduceACursorPaginatorDataCollectionUnionType(): void + { + $type = $this->resolveDataType(new class { + #[DataCollectionOf(SimpleData::class)] + public CursorPaginator|Lazy $property; + }); + + $this->assertFalse($type->isOptional); + $this->assertFalse($type->isNullable); + $this->assertFalse($type->isMixed); + $this->assertSame(Lazy::class, $type->lazyType); + $this->assertValueType($type, CursorPaginator::class, false, DataTypeKind::DataCursorPaginator, SimpleData::class, CursorPaginator::class); + } + + // REMOVED: 'cannot have multiple data types', 'cannot combine a data object and another type' and 'cannot combine + // a data collection and another type'; skipped upstream, and Hypervel declares these unions and resolves each + // value against them (README). + + /** + * @param array> $expected + */ + #[DataProvider('baseTypesForAcceptedTypes')] + public function testWillResolveTheBaseTypesForAcceptedTypes(object $class, array $expected): void + { + $type = $this->resolveDataType($class); + + // Spatie stores each accepted type's parents and interfaces; Hypervel checks them with is_a(), so every + // listed base type must lead back to its declared type. An empty map is a mixed declaration. + $this->assertSame($expected === [], $type->isMixed); + + foreach ($expected as $acceptedType => $baseTypes) { + foreach ([$acceptedType, ...$baseTypes] as $baseType) { + $this->assertSame($acceptedType, $type->findAcceptedTypeForBaseType($baseType)); + } + } + } + + /** + * Get declarations with the base types each accepted type resolves from. + * + * Spatie's ContextableData is not included in Hypervel (README). + * + * @return iterable>}> + */ + public static function baseTypesForAcceptedTypes(): iterable + { + yield 'no type' => [ + new class { + public $property; + }, + [], + ]; + + yield 'mixed' => [ + new class { + public mixed $property; + }, + [], + ]; + + yield 'single' => [ + new class { + public string $property; + }, + ['string' => []], + ]; + + yield 'multi' => [ + new class { + public string|int|bool|float|array $property; + }, + [ + 'string' => [], + 'int' => [], + 'bool' => [], + 'float' => [], + 'array' => [], + ], + ]; + + yield 'data' => [ + new class { + public SimpleData $property; + }, + [ + SimpleData::class => [ + Data::class, + JsonSerializable::class, + Castable::class, + Jsonable::class, + Responsable::class, + Arrayable::class, + AppendableData::class, + BaseData::class, + IncludeableData::class, + ResponsableData::class, + TransformableData::class, + ValidateableData::class, + WrappableData::class, + EmptyData::class, + ], + ], + ]; + + yield 'enum' => [ + new class { + public DummyBackedEnum $property; + }, + [ + DummyBackedEnum::class => [ + UnitEnum::class, + BackedEnum::class, + ], + ], + ]; + } + + #[DataProvider('acceptedTypes')] + public function testCanCheckIfADataTypeAcceptsAType(object $class, string $type, bool $accepts): void + { + $this->assertSame($accepts, $this->resolveDataType($class)->acceptsType($type)); + } + + /** + * Get declarations with a type name and whether they accept it. + * + * @return iterable + */ + public static function acceptedTypes(): iterable + { + // Base types + + yield [ + new class { + public $property; + }, + 'string', + true, + ]; + + yield [ + new class { + public mixed $property; + }, + 'string', + true, + ]; + + yield [ + new class { + public string $property; + }, + 'string', + true, + ]; + + yield [ + new class { + public bool $property; + }, + 'bool', + true, + ]; + + yield [ + new class { + public int $property; + }, + 'int', + true, + ]; + + yield [ + new class { + public float $property; + }, + 'float', + true, + ]; + + yield [ + new class { + public array $property; + }, + 'array', + true, + ]; + + yield [ + new class { + public string $property; + }, + 'array', + false, + ]; + + // Objects + + yield [ + new class { + public SimpleData $property; + }, + SimpleData::class, + true, + ]; + + yield [ + new class { + public SimpleData $property; + }, + ComplicatedData::class, + false, + ]; + + // Objects with inheritance + + yield 'simple inheritance' => [ + new class { + public Data $property; + }, + SimpleData::class, + true, + ]; + + yield 'reversed inheritance' => [ + new class { + public SimpleData $property; + }, + Data::class, + false, + ]; + + yield 'false inheritance' => [ + new class { + public Model $property; + }, + SimpleData::class, + false, + ]; + + // Objects with interfaces + + yield 'simple interface implementation' => [ + new class { + public DateTimeInterface $property; + }, + DateTime::class, + true, + ]; + + yield 'reversed interface implementation' => [ + new class { + public DateTime $property; + }, + DateTimeInterface::class, + false, + ]; + + yield 'false interface implementation' => [ + new class { + public Model $property; + }, + DateTime::class, + false, + ]; + + // Enums + + yield [ + new class { + public DummyBackedEnum $property; + }, + DummyBackedEnum::class, + true, + ]; + } + + #[DataProvider('acceptedValues')] + public function testCanCheckIfADataTypeAcceptsAValue(object $class, mixed $value, bool $accepts): void + { + $this->assertSame($accepts, $this->resolveDataType($class)->acceptsValue($value)); + } + + /** + * Get declarations with a value and whether they accept it. + * + * @return iterable + */ + public static function acceptedValues(): iterable + { + yield [ + new class { + public ?string $property; + }, + null, + true, + ]; + + yield [ + new class { + public string $property; + }, + 'Hello', + true, + ]; + + yield [ + new class { + public string $property; + }, + 3.14, + false, + ]; + + yield [ + new class { + public mixed $property; + }, + 3.14, + true, + ]; + + yield [ + new class { + public Data $property; + }, + new SimpleData('Hello'), + true, + ]; + + yield [ + new class { + public SimpleData $property; + }, + new SimpleData('Hello'), + true, + ]; + + yield [ + new class { + public SimpleData $property; + }, + new SimpleDataWithMappedProperty('Hello'), + false, + ]; + + yield [ + new class { + public DummyBackedEnum $property; + }, + DummyBackedEnum::FOO, + true, + ]; + } + + #[DataProvider('acceptedTypesForBaseTypes')] + public function testCanFindAcceptedTypeForABaseType(object $class, string $type, ?string $expectedType): void + { + $this->assertSame($expectedType, $this->resolveDataType($class)->findAcceptedTypeForBaseType($type)); + } + + /** + * Get declarations with a base type and the declared type it finds. + * + * @return iterable + */ + public static function acceptedTypesForBaseTypes(): iterable + { + yield [ + new class { + public SimpleData $property; + }, + SimpleData::class, + SimpleData::class, + ]; + + yield [ + new class { + public SimpleData $property; + }, + Data::class, + SimpleData::class, + ]; + + yield [ + new class { + public DummyBackedEnum $property; + }, + BackedEnum::class, + DummyBackedEnum::class, + ]; + + yield [ + new class { + public SimpleData $property; + }, + DataCollection::class, + null, + ]; + } + + public function testCanAnnotateDataCollectionsUsingAttributes(): void + { + $type = $this->resolveDataType(new class { + #[DataCollectionOf(SimpleData::class)] + public DataCollection $property; + }); + + $this->assertValueType($type, DataCollection::class, false, DataTypeKind::DataCollection, SimpleData::class, DataCollection::class); + } + + public function testCanAnnotateDataCollectionsUsingVarAnnotations(): void + { + $type = $this->resolveDataType(new class { + /** @var DataCollection */ + public DataCollection $property; + }); + + $this->assertValueType($type, DataCollection::class, false, DataTypeKind::DataCollection, SimpleData::class, DataCollection::class); + } + + public function testCanAnnotateDataCollectionsUsingPropertyAnnotations(): void + { + $type = $this->resolveDataType(new TestDataTypeWithClassAnnotatedProperty([])); + + $this->assertValueType($type, 'array', true, DataTypeKind::DataArray, SimpleData::class, 'array'); + } + + public function testCanAnnotateDataCollectionsUsingConstructorParameterAnnotations(): void + { + $type = $this->resolveDataType(new TestDataTypeWithClassAnnotatedConstructorParam([])); + + $this->assertValueType($type, 'array', true, DataTypeKind::DataArray, SimpleData::class, 'array'); + } + + public function testCanDeduceTheTypesOfLazy(): void + { + $type = $this->resolveDataType(new class { + public SimpleData|Lazy $property; + }); + + $this->assertSame(Lazy::class, $type->lazyType); + + $type = $this->resolveDataType(new class { + public SimpleData|ClosureLazy $property; + }); + + $this->assertSame(ClosureLazy::class, $type->lazyType); + + $type = $this->resolveDataType(new class { + public SimpleData|InertiaLazy $property; + }); + + $this->assertSame(InertiaLazy::class, $type->lazyType); + + $type = $this->resolveDataType(new class { + public SimpleData|ConditionalLazy $property; + }); + + $this->assertSame(ConditionalLazy::class, $type->lazyType); + + $type = $this->resolveDataType(new class { + public SimpleData|RelationalLazy $property; + }); + + $this->assertSame(RelationalLazy::class, $type->lazyType); + } + + public function testWillMarkAnArrayCollectionAndPaginatorsAsAnIterableTypeKindWhenNoDataCollectionWasSpecified(): void + { + $type = $this->resolveDataType(new class { + public array $property; + }); + + $this->assertSame(DataTypeKind::Array, $this->valueType($type)->kind); + + $type = $this->resolveDataType(new class { + public Collection $property; + }); + + $this->assertSame(DataTypeKind::Enumerable, $this->valueType($type)->kind); + + $type = $this->resolveDataType(new class { + public LengthAwarePaginator $property; + }); + + $this->assertSame(DataTypeKind::Paginator, $this->valueType($type)->kind); + + $type = $this->resolveDataType(new class { + public CursorPaginator $property; + }); + + $this->assertSame(DataTypeKind::CursorPaginator, $this->valueType($type)->kind); + } + + public function testCanAnnotateIterablesUsingAttributes(): void + { + $type = $this->resolveDataType(new class { + #[DataCollectionOf(SimpleData::class)] + public DataCollection $property; + }); + + $this->assertValueType($type, DataCollection::class, false, DataTypeKind::DataCollection, SimpleData::class, DataCollection::class); + } + + public function testCanAnnotateIterablesUsingVarAnnotations(): void + { + $type = $this->resolveDataType(new class { + /** @var array */ + public array $property; + }); + + $this->assertValueType($type, 'array', true, DataTypeKind::Array, null, 'array'); + $this->assertSame('string', $this->iterableItemTypeName($type)); + } + + public function testCanAnnotateIterablesUsingPropertyAnnotations(): void + { + $type = $this->resolveDataType(new TestDataTypeWithClassAnnotatedNonDataProperty(new Collection)); + + $this->assertValueType($type, Collection::class, false, DataTypeKind::Enumerable, null, Collection::class); + $this->assertSame('string', $this->iterableItemTypeName($type)); + } + + public function testCanAnnotateIterablesUsingConstructorParameterAnnotations(): void + { + $type = $this->resolveDataType(new TestDataTypeWithClassAnnotatedNonDataConstructorParam([])); + + $this->assertValueType($type, 'array', true, DataTypeKind::Array, null, 'array'); + $this->assertSame('string', $this->iterableItemTypeName($type)); + } + + // Iterable key types are not tracked: they only fed Spatie's TypeScript transformer, which is not included, so + // the two key cases below assert the item types their annotations give. + + public function testCanAnnotateDataCollectionKeysUsingVarAnnotations(): void + { + $type = $this->resolveDataType(new class { + /** @var array */ + public array $property; + }); + + $this->assertValueType($type, 'array', true, DataTypeKind::DataArray, SimpleData::class, 'array'); + $this->assertSame(SimpleData::class, $this->iterableItemTypeName($type)); + } + + public function testCanAnnotateIterableCollectionKeysUsingVarAnnotations(): void + { + $type = $this->resolveDataType(new class { + /** @var array */ + public array $property; + }); + + $this->assertValueType($type, 'array', true, DataTypeKind::Array, null, 'array'); + $this->assertSame('string', $this->iterableItemTypeName($type)); + } + + /** + * Resolve the type of one property of a fake class. + */ + protected function resolveDataType(object $class, string $property = 'property'): DataPropertyType + { + return $this->factory()->build(new ReflectionClass($class))->properties[$property]->type; + } + + /** + * Assert the one declared type that holds values. + */ + protected function assertValueType( + DataPropertyType $type, + string $name, + bool $builtIn, + DataTypeKind $kind, + ?string $dataClass = null, + ?string $iterableClass = null, + ): void { + $valueType = $this->valueType($type); + + $this->assertSame($name, $valueType->name); + $this->assertSame($builtIn, $valueType->builtIn); + $this->assertSame($kind, $valueType->kind); + $this->assertSame($dataClass, $valueType->dataClass); + $this->assertSame($iterableClass, $valueType->iterableClass); + } + + /** + * Get the one declared type that holds values, leaving out null, Optional and Lazy. + */ + protected function valueType(DataPropertyType $type): NamedType + { + $valueTypes = $this->valueTypes($type); + + $this->assertCount(1, $valueTypes); + + return $valueTypes[0]; + } + + /** + * Get the names of the declared types that hold values. + * + * @return list + */ + protected function valueTypeNames(DataPropertyType $type): array + { + return array_map(fn (NamedType $namedType): string => $namedType->name, $this->valueTypes($type)); + } + + /** + * Get the declared types that hold values, leaving out null, Optional and Lazy. + * + * @return list + */ + protected function valueTypes(DataPropertyType $type): array + { + return array_values(array_filter( + $type->getNamedTypes(), + fn (NamedType $namedType): bool => $namedType->name !== 'null' + && ! is_a($namedType->name, Optional::class, true) + && ! is_a($namedType->name, Lazy::class, true), + )); + } + + /** + * Get the item type name of the one iterable declaration. + */ + protected function iterableItemTypeName(DataPropertyType $type): ?string + { + return $this->valueType($type)->iterableItemType?->getNamedTypes()[0]->name; + } + + /** + * Create the data class metadata factory. + */ + protected function factory(): DataClassFactory + { + $defaults = require __DIR__ . '/../../../src/data/config/data.php'; + $config = new DataConfig(new Repository(['data' => $defaults])); + $nameMapperResolver = new NameMapperResolver(new Container); + $typeFactory = new DataTypeFactory(new PhpDocTypeNameResolver, new DataIterableAnnotationReader); + $parameterFactory = new DataParameterFactory($typeFactory); + + return new DataClassFactory( + new DataPropertyFactory($typeFactory, $config, $nameMapperResolver), + new DataMethodFactory($parameterFactory, $typeFactory), + $parameterFactory, + new DataIterableAnnotationReader, + $nameMapperResolver, + $config, + ); + } +} + +/** + * @property DataCollection $property + */ +class TestDataTypeWithClassAnnotatedProperty +{ + /** + * Create a fake whose property is annotated on the class. + */ + public function __construct( + public array $property, + ) { + } +} + +class TestDataTypeWithClassAnnotatedConstructorParam +{ + /** + * Create a fake whose property is annotated on the constructor. + * + * @param array $property + */ + public function __construct( + public array $property, + ) { + } +} + +/** + * @property Collection $property + */ +class TestDataTypeWithClassAnnotatedNonDataProperty +{ + /** + * Create a fake whose non-data property is annotated on the class. + */ + public function __construct( + public Collection $property, + ) { + } +} + +class TestDataTypeWithClassAnnotatedNonDataConstructorParam +{ + /** + * Create a fake whose non-data property is annotated on the constructor. + * + * @param array $property + */ + public function __construct( + public array $property, + ) { + } +} diff --git a/tests/Data/Support/DataTypeFactoryTest.php b/tests/Data/Support/DataTypeFactoryTest.php index 0162529ce6..71e635e930 100644 --- a/tests/Data/Support/DataTypeFactoryTest.php +++ b/tests/Data/Support/DataTypeFactoryTest.php @@ -18,10 +18,14 @@ use Hypervel\Data\Support\Types\PhpDocTypeNameResolver; use Hypervel\Data\Support\Types\UnionType; use Hypervel\Database\Eloquent\Collection as EloquentCollection; +use Hypervel\Database\Eloquent\Model; use Hypervel\Support\Collection; +use Hypervel\Tests\Data\Fixtures\Collections\SimpleDataCollection; +use Hypervel\Tests\Data\Fixtures\SimpleData; use Hypervel\Tests\Data\Fixtures\Types\ImportedData as GroupedImportedData; use Hypervel\Tests\TestCase; use Mockery as m; +use PHPUnit\Framework\Attributes\DataProvider; use ReflectionClass; use ReflectionMethod; use ReflectionProperty; @@ -192,6 +196,72 @@ public function testExactIterableAnnotationsWinRegardlessOfUnionOrder(): void } } + public function testItemShorthandsApplyToAnyContainerAfterExactMatches(): void + { + $this->assertSame( + DataTypeFactoryFirstItemData::class, + $this->property('shorthandDataCollection')->getDataCollectableType()?->dataClass, + ); + $this->assertSame( + DataTypeFactoryFirstItemData::class, + $this->property('shorthandCollection')->getDataCollectableType()?->dataClass, + ); + + $types = []; + + foreach ($this->property('shorthandWithExactArm')->getDataCollectableTypes() as $type) { + $types[$type->name] = $type->dataClass; + } + + $this->assertEquals([ + 'array' => DataTypeFactoryFirstItemData::class, + Collection::class => DataTypeFactorySecondItemData::class, + ], $types); + } + + public function testAnotherCollectionsAnnotationGivesTheItemsOnlyWhenUnambiguous(): void + { + $types = []; + + foreach ($this->property('collectionAnnotationOnUnion')->getDataCollectableTypes() as $type) { + $types[$type->name] = $type->dataClass; + } + + $this->assertEquals([ + 'array' => DataTypeFactoryFirstItemData::class, + Collection::class => DataTypeFactoryFirstItemData::class, + ], $types); + $this->assertNull($this->property('boxAnnotationOnArray')->getNamedTypes()[0]->iterableItemType); + $this->assertNull($this->property('competingCollectionAnnotationsOnArray')->getNamedTypes()[0]->iterableItemType); + // An imported and a qualified name for the same item do not compete. + $this->assertSame( + DataTypeFactoryFirstItemData::class, + $this->property('sameItemCollectionAnnotationsOnArray')->getNamedTypes()[0]->dataClass, + ); + } + + public function testCollectionClassItemTypesApplyAfterDeclaredItemTypes(): void + { + // The collection class imports SimpleData by its short name, so it resolves in the collection's own file. + $this->assertSame( + SimpleData::class, + $this->property('classAnnotatedCollection')->getDataCollectableType()?->dataClass, + ); + $this->assertSame( + DataTypeFactoryFirstItemData::class, + $this->property('attributeOverCollectionClass')->getDataCollectableType()?->dataClass, + ); + $this->assertSame( + DataTypeFactorySecondItemData::class, + $this->property('annotationOverCollectionClass')->getDataCollectableType()?->dataClass, + ); + + $eloquent = $this->property('eloquentCollection')->getNamedTypes()[0]; + + $this->assertSame(DataTypeKind::Enumerable, $eloquent->kind); + $this->assertSame(Model::class, $eloquent->iterableItemType?->getNamedTypes()[0]->name); + } + /** * Test data object declarations and float widening. */ @@ -221,7 +291,7 @@ public function testNamedTypesUseNativePhpAcceptanceRules(): void */ public function testInheritedNativeTypesUseTheirPhpScopes(): void { - $factory = new DataTypeFactory(new PhpDocTypeNameResolver); + $factory = new DataTypeFactory(new PhpDocTypeNameResolver, new DataIterableAnnotationReader); $target = new ReflectionClass(DataTypeFactoryNativeChild::class); $selfProperty = new ReflectionProperty(DataTypeFactoryNativeChild::class, 'selfValue'); $parentProperty = new ReflectionProperty(DataTypeFactoryNativeChild::class, 'parentValue'); @@ -294,6 +364,66 @@ public function testInheritedPhpDocKeywordsUseTheirPhpScopes(): void ); } + // Spatie's DataReturnTypeTest: return types are built by this factory. Spatie's buildFromNamedType() and + // buildFromValue() only resolved collect() targets, which Hypervel resolves without type metadata. + + #[DataProvider('returnTypes')] + public function testCanDetermineTheReturnTypeFromReflection( + string $methodName, + string $name, + bool $builtIn, + DataTypeKind $kind, + ): void { + $method = new ReflectionMethod(TestReturnTypeSubject::class, $methodName); + $type = (new DataTypeFactory(new PhpDocTypeNameResolver, new DataIterableAnnotationReader)) + ->build($method->getReturnType(), TestReturnTypeSubject::class, $method); + + $this->assertFalse($type->isNullable); + $this->assertFalse($type->isMixed); + $this->assertInstanceOf(NamedType::class, $type->type); + $this->assertSame($name, $type->type->name); + $this->assertSame($builtIn, $type->type->builtIn); + $this->assertSame($kind, $type->type->kind); + // Every iterable kind records its container, as Spatie does for property types. + $this->assertSame($name, $type->type->iterableClass); + } + + /** + * Get the return types of the subject's methods. + * + * @return array + */ + public static function returnTypes(): array + { + return [ + 'array' => ['array', 'array', true, DataTypeKind::Array], + 'collection' => ['collection', Collection::class, false, DataTypeKind::Enumerable], + 'data collection' => ['dataCollection', DataCollection::class, false, DataTypeKind::DataCollection], + ]; + } + + public function testCanHandleUnionTypes(): void + { + $method = new ReflectionMethod(TestReturnTypeSubject::class, 'union'); + $type = (new DataTypeFactory(new PhpDocTypeNameResolver, new DataIterableAnnotationReader)) + ->build($method->getReturnType(), TestReturnTypeSubject::class, $method); + + $this->assertFalse($type->isNullable); + $this->assertFalse($type->isMixed); + $this->assertInstanceOf(UnionType::class, $type->type); + $this->assertSame( + [Collection::class => DataTypeKind::Enumerable, 'array' => DataTypeKind::Array], + array_column( + array_map(fn (NamedType $namedType): array => [$namedType->name, $namedType->kind], $type->getNamedTypes()), + 1, + 0, + ), + ); + } + + // REMOVED: 'will store return types in the factory as a caching mechanism' and 'will cache nullable and non + // nullable return types separately'; metadata is built once per worker, so the factory keeps no cache. + /** * Build metadata for one fixture property. */ @@ -317,7 +447,7 @@ protected function propertyFor(string $className, string $name, array $annotatio $property = new ReflectionProperty($className, $name); $attributes = DataAttributesCollectionFactory::buildFromReflectionProperty($property); - return (new DataTypeFactory(new PhpDocTypeNameResolver))->buildProperty( + return (new DataTypeFactory(new PhpDocTypeNameResolver, new DataIterableAnnotationReader))->buildProperty( $property->getType(), $class, $property, @@ -411,6 +541,37 @@ class DataTypeFactoryFixture /** @var Collection|EloquentCollection */ public EloquentCollection|Collection $annotationExactFirst; + /** @var DataTypeFactoryFirstItemData[] */ + public DataCollection $shorthandDataCollection; + + /** @var array */ + public Collection $shorthandCollection; + + /** @var Collection|DataTypeFactoryFirstItemData[] */ + public array|Collection $shorthandWithExactArm; + + /** @var Collection */ + public array|Collection $collectionAnnotationOnUnion; + + /** @var DataTypeFactoryBox */ + public array $boxAnnotationOnArray; + + /** @var Collection|EloquentCollection */ + public array $competingCollectionAnnotationsOnArray; + + /** @var Collection|EloquentCollection */ + public array $sameItemCollectionAnnotationsOnArray; + + public SimpleDataCollection $classAnnotatedCollection; + + #[DataCollectionOf(DataTypeFactoryFirstItemData::class)] + public SimpleDataCollection $attributeOverCollectionClass; + + /** @var SimpleDataCollection */ + public SimpleDataCollection $annotationOverCollectionClass; + + public EloquentCollection $eloquentCollection; + public float $float; } @@ -418,6 +579,13 @@ abstract class DataTypeFactoryFirstItemData implements BaseData { } +/** + * @template TValue + */ +class DataTypeFactoryBox +{ +} + abstract class DataTypeFactorySecondItemData implements BaseData { } @@ -476,3 +644,38 @@ class DataTypeFactoryPhpDocParent extends DataTypeFactoryPhpDocGrandparent class DataTypeFactoryPhpDocChild extends DataTypeFactoryPhpDocParent { } + +class TestReturnTypeSubject +{ + /** + * Return an array. + */ + public function array(): array + { + return []; + } + + /** + * Return a collection. + */ + public function collection(): Collection + { + return new Collection; + } + + /** + * Return a data collection. + */ + public function dataCollection(): DataCollection + { + return new DataCollection(SimpleData::class, []); + } + + /** + * Return an array or a collection. + */ + public function union(): array|Collection + { + return []; + } +} diff --git a/tests/Data/Support/Lazy/InertiaLazyTest.php b/tests/Data/Support/Lazy/InertiaLazyTest.php new file mode 100644 index 0000000000..b09a11ed33 --- /dev/null +++ b/tests/Data/Support/Lazy/InertiaLazyTest.php @@ -0,0 +1,27 @@ + 'value'); + + $this->assertInstanceOf(InertiaLazy::class, $lazy); + $this->assertTrue($lazy->shouldBeIncluded()); + $this->assertFalse($lazy->resolvesToData()); + $this->assertInstanceOf(OptionalProp::class, $lazy->resolve()); + $this->assertSame('value', ($lazy->resolve())()); + } +} diff --git a/tests/Data/Support/NameMapperResolverTest.php b/tests/Data/Support/NameMapperResolverTest.php new file mode 100644 index 0000000000..630d6ea17e --- /dev/null +++ b/tests/Data/Support/NameMapperResolverTest.php @@ -0,0 +1,182 @@ +assertEquals([ + 'inputNameMapper' => new ProvidedNameMapper('input'), + 'outputNameMapper' => new ProvidedNameMapper('output'), + ], $this->mappers(new class { + #[MapInputName('input'), MapOutputName('output')] + public string $property; + })); + } + + public function testCanHaveNoMappers(): void + { + $this->assertSame([ + 'inputNameMapper' => null, + 'outputNameMapper' => null, + ], $this->mappers(new class { + public string $property; + })); + } + + public function testCanHaveASingleMapAttribute(): void + { + $this->assertEquals([ + 'inputNameMapper' => new ProvidedNameMapper('input'), + 'outputNameMapper' => new ProvidedNameMapper('output'), + ], $this->mappers(new class { + #[MapName('input', 'output')] + public string $property; + })); + } + + public function testCanOverwriteAGeneralMapAttribute(): void + { + $this->assertEquals([ + 'inputNameMapper' => new ProvidedNameMapper('input_overwritten'), + 'outputNameMapper' => new ProvidedNameMapper('output'), + ], $this->mappers(new class { + #[MapName('input', 'output'), MapInputName('input_overwritten')] + public string $property; + })); + } + + public function testCanMapAnInt(): void + { + $this->assertEquals([ + 'inputNameMapper' => new ProvidedNameMapper(0), + 'outputNameMapper' => new ProvidedNameMapper(3), + ], $this->mappers(new class { + #[MapName(0, 3)] + public string $property; + })); + } + + public function testCanMapAString(): void + { + $this->assertEquals([ + 'inputNameMapper' => new ProvidedNameMapper('hello'), + 'outputNameMapper' => new ProvidedNameMapper('world'), + ], $this->mappers(new class { + #[MapName('hello', 'world')] + public string $property; + })); + } + + public function testCanMapAMapperClass(): void + { + $this->assertEquals([ + 'inputNameMapper' => new CamelCaseMapper, + 'outputNameMapper' => new SnakeCaseMapper, + ], $this->mappers(new class { + #[MapName(CamelCaseMapper::class, SnakeCaseMapper::class)] + public string $property; + })); + } + + #[WithConfig('data.name_mapping_strategy.input', CamelCaseMapper::class)] + #[WithConfig('data.name_mapping_strategy.output', SnakeCaseMapper::class)] + public function testCanHaveDefaultMappers(): void + { + $this->assertEquals([ + 'inputNameMapper' => new CamelCaseMapper, + 'outputNameMapper' => new SnakeCaseMapper, + ], $this->mappers(new class { + public string $property; + })); + } + + #[WithConfig('data.name_mapping_strategy.input', CamelCaseMapper::class)] + #[WithConfig('data.name_mapping_strategy.output', SnakeCaseMapper::class)] + public function testInputNameMappersOnlyWorkWhenNoMappersAreSpecified(): void + { + $this->assertEquals([ + 'inputNameMapper' => new StudlyCaseMapper, + 'outputNameMapper' => new SnakeCaseMapper, + ], $this->mappers(new class { + #[MapInputName(StudlyCaseMapper::class)] + public string $property; + })); + } + + #[WithConfig('data.name_mapping_strategy.input', CamelCaseMapper::class)] + #[WithConfig('data.name_mapping_strategy.output', SnakeCaseMapper::class)] + public function testOutputNameMappersOnlyWorkWhenNoMappersAreSpecified(): void + { + $this->assertEquals([ + 'inputNameMapper' => new CamelCaseMapper, + 'outputNameMapper' => new StudlyCaseMapper, + ], $this->mappers(new class { + #[MapOutputName(StudlyCaseMapper::class)] + public string $property; + })); + } + + public function testCanIgnoreCertainMapperTypes(): void + { + // Spatie ignores provided names through a resolver option; a class-level name is dropped by the class metadata. + $property = $this->app->make(DataClassRepository::class) + ->get(ClassLevelProvidedNameData::class) + ->properties['someProperty']; + + $this->assertNull($property->inputMappedName); + $this->assertSame('some_property', $property->outputMappedName); + } + + /** + * Resolve a property's input and output mappers with the configured defaults. + * + * @return array{inputNameMapper: mixed, outputNameMapper: mixed} + */ + private function mappers(object $class): array + { + $resolver = $this->app->make(NameMapperResolver::class); + $config = $this->app->make(DataConfig::class); + $attributes = DataAttributesCollectionFactory::buildFromReflectionProperty(new ReflectionProperty($class, 'property')); + + return [ + 'inputNameMapper' => $resolver->resolveInput($attributes, $resolver->resolveConfigured($config->inputNameMapper)), + 'outputNameMapper' => $resolver->resolveOutput($attributes, $resolver->resolveConfigured($config->outputNameMapper)), + ]; + } +} + +#[MapInputName('input'), MapOutputName(SnakeCaseMapper::class)] +class ClassLevelProvidedNameData extends Data +{ + public string $someProperty; +} diff --git a/tests/Data/Support/Transformation/PartialsDefinitionTest.php b/tests/Data/Support/Partials/PartialsDefinitionTest.php similarity index 76% rename from tests/Data/Support/Transformation/PartialsDefinitionTest.php rename to tests/Data/Support/Partials/PartialsDefinitionTest.php index e59b4c068d..106efc26bb 100644 --- a/tests/Data/Support/Transformation/PartialsDefinitionTest.php +++ b/tests/Data/Support/Partials/PartialsDefinitionTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Tests\Data\Support\Transformation; +namespace Hypervel\Tests\Data\Support\Partials; use Hypervel\Data\Support\Partials\PartialDefinition; use Hypervel\Data\Support\Partials\PartialsDefinition; @@ -123,6 +123,44 @@ public function testAddsDefinitionsResolvedByAnEnclosingObject(): void ); } + /** + * Test repeated resolved copies are added once, without merging conditions or lifetimes. + */ + public function testSkipsResolvedDefinitionsAlreadyHeld(): void + { + $definitions = new PartialsDefinition; + $definitions->add('include', 'conditional', permanent: true, condition: static fn (): bool => true); + $resolved = [ + 'include' => [ + new PartialDefinition('temporary'), + new PartialDefinition('temporary', permanent: true), + new PartialDefinition('conditional', permanent: true), + ], + 'exclude' => [], + 'only' => [new PartialDefinition('temporary')], + 'except' => [], + ]; + $data = new stdClass; + + $definitions->addResolved($resolved); + $definitions->addResolved($resolved); + + $this->assertSame( + ['conditional', 'temporary', 'temporary', 'conditional'], + self::paths($definitions->resolve($data)['include']), + ); + $this->assertSame(['temporary'], self::paths($definitions->resolve($data)['only'])); + + $definitions->resolve($data, consumeTemporary: true); + $definitions->addResolved($resolved); + + $this->assertSame( + ['conditional', 'temporary', 'conditional', 'temporary'], + self::paths($definitions->resolve($data)['include']), + ); + $this->assertSame(['temporary'], self::paths($definitions->resolve($data)['only'])); + } + /** * Test conditional definitions survive PHP serialization. */ @@ -179,13 +217,18 @@ public function testResolvesDefinitionsForNestedProperties( /** * Provide nested definition paths. + * + * Spatie's DecoupledPartialResolverTest cases are the first five rows; nested() replaces the resolver, + * and its "undefined" case is the first row, since a path has no pointer to advance past its end. */ public static function nestedDefinitionProvider(): array { return [ - 'terminal selection' => ['nested', 'nested', null], - 'nested wildcard' => ['nested.*', 'nested', '*'], - 'root wildcard' => ['*', 'nested', '*'], + 'no further fields' => ['nested', 'nested', null], + 'single field' => ['nested.name', 'nested', 'name'], + 'nested field' => ['nested.child.name', 'nested', 'child.name'], + 'all field' => ['nested.*', 'nested', '*'], + 'all field a few levels deep' => ['*', 'nested', '*'], 'nested group' => ['nested.{a,b}', 'nested', '{a,b}'], 'root group' => ['{nested,other}', 'nested', null], 'different property' => ['other.value', 'nested', null], diff --git a/tests/Data/Support/PhpDocTypeNameResolverTest.php b/tests/Data/Support/PhpDocTypeNameResolverTest.php index 8c4c7bee2d..d88863d50d 100644 --- a/tests/Data/Support/PhpDocTypeNameResolverTest.php +++ b/tests/Data/Support/PhpDocTypeNameResolverTest.php @@ -16,8 +16,12 @@ use ReflectionClass; use ReflectionProperty; +use function Hypervel\Tests\Data\Fixtures\Second\multiNamespaceAnonymous; + class PhpDocTypeNameResolverTest extends TestCase { + // REMOVED: Spatie's ContextResolverTest; PHPDoc names resolve through this resolver instead of phpDocumentor contexts. Class resolution and the per-file cache are covered here, and property, parameter and method types resolve through their declaring class (DataTypeFactoryTest). + /** * Test built-in, fully qualified, and declaration-relative names. */ @@ -72,13 +76,27 @@ public function testOneSourceMapContainsEveryNamespace(): void $this->assertCount(1, $this->imports($resolver)); } + public function testAnonymousClassesUseTheirSourceNamespace(): void + { + require_once __DIR__ . '/../Fixtures/MultiNamespacePhpDocTypes.php'; + + $resolver = new PhpDocTypeNameResolver; + $anonymous = new ReflectionClass(new class extends PhpDocTypeContext { + }); + $multiNamespace = new ReflectionClass(multiNamespaceAnonymous()); + + $this->assertSame(ImportedType::class, $resolver->resolve('ImportedType', $anonymous)); + $this->assertSame(__NAMESPACE__ . '\UnimportedType', $resolver->resolve('UnimportedType', $anonymous)); + $this->assertSame(GroupedType::class, $resolver->resolve('SharedAlias', $multiNamespace)); + } + /** - * Get the resolver's bounded source import cache. + * Get the resolver's bounded source cache. * - * @return array>> + * @return array>, namespaces: array}> */ protected function imports(PhpDocTypeNameResolver $resolver): array { - return (new ReflectionProperty($resolver, 'imports'))->getValue($resolver); + return (new ReflectionProperty($resolver, 'sources'))->getValue($resolver); } } diff --git a/tests/Data/Support/Transformation/DataContextTest.php b/tests/Data/Support/Transformation/DataContextTest.php new file mode 100644 index 0000000000..c543bc508a --- /dev/null +++ b/tests/Data/Support/Transformation/DataContextTest.php @@ -0,0 +1,59 @@ +include('basic') + ->includePermanently('permanent') + ->includeWhen('conditional', static fn (SimpleData $data): bool => $data->string === 'Hello') + ->include('nested.field', '*') + ->wrap('key'); + + /** @var SimpleData $restored */ + $restored = unserialize(serialize($data)); + + $this->assertEquals(new Wrap(WrapType::Defined, 'key'), $restored->getWrap()); + $this->assertSame([ + ['basic', false], + ['permanent', true], + ['conditional', false], + ['nested.field', false], + ['*', false], + ], self::includes($restored, $restored)); + $this->assertSame([ + ['basic', false], + ['permanent', true], + ['nested.field', false], + ['*', false], + ], self::includes($restored, new SimpleData('Other'))); + } + + /** + * Get the include paths and lifetimes that apply to an object. + * + * @return list + */ + private static function includes(SimpleData $owner, object $data): array + { + return array_map( + static fn (PartialDefinition $definition): array => [$definition->path, $definition->permanent], + $owner->getPartialsDefinition()->resolve($data)['include'], + ); + } +} diff --git a/tests/Data/Support/Transformation/DataTransformerTest.php b/tests/Data/Support/Transformation/DataTransformerTest.php index 07c0ec51a6..08dc854631 100644 --- a/tests/Data/Support/Transformation/DataTransformerTest.php +++ b/tests/Data/Support/Transformation/DataTransformerTest.php @@ -24,7 +24,9 @@ use Hypervel\Data\DataCollection; use Hypervel\Data\DataServiceProvider; use Hypervel\Data\Dto; +use Hypervel\Data\Exceptions\CannotPerformPartialOnDataField; use Hypervel\Data\Exceptions\CannotTransformData; +use Hypervel\Data\Exceptions\MaxTransformationDepthReached; use Hypervel\Data\Lazy; use Hypervel\Data\Normalizers\Normalized\Normalized; use Hypervel\Data\Normalizers\Normalizer; @@ -40,6 +42,8 @@ use Hypervel\Inertia\OptionalProp; use Hypervel\Pagination\CursorPaginator; use Hypervel\Pagination\LengthAwarePaginator; +use Hypervel\Pagination\Paginator; +use Hypervel\Testbench\Attributes\WithConfig; use Hypervel\Testbench\TestCase; use ReflectionProperty; use RuntimeException; @@ -47,8 +51,6 @@ class DataTransformerTest extends TestCase { - // REMOVED: SerializeTransformer tests; native PHP serialization owns object serialization. - /** * Get package providers for the transformation test application. */ @@ -173,12 +175,13 @@ public function testDataAnnotatedArraysPreventBulkCopy(): void } /** - * Test nested paginator properties retain native metadata. + * Test nested paginator properties transform into their items, links, and metadata. */ - public function testTransformsNestedOffsetAndCursorPaginatorsWithMetadata(): void + public function testTransformsNestedPaginatorsWithLinksAndMetadata(): void { $offsetItem = new PaginatorItemData(1, 'offset'); $cursorItem = new PaginatorItemData(2, 'cursor'); + $simpleItem = new PaginatorItemData(4, 'simple'); $offset = new LengthAwarePaginator( [$offsetItem], 12, @@ -195,25 +198,76 @@ public function testTransformsNestedOffsetAndCursorPaginatorsWithMetadata(): voi 'parameters' => ['id'], ], ); + $simple = new Paginator( + [$simpleItem, new PaginatorItemData(5, 'more')], + 1, + 3, + ['path' => '/simple'], + ); - $transformed = (new PaginatorOwnerData($offset, $cursor))->toArray(); + $transformed = (new PaginatorOwnerData($offset, $cursor, $simple))->toArray(); + $this->assertSame([['id' => 1, 'label_text' => 'offset']], $transformed['offset']['data']); + $this->assertCount(5, $transformed['offset']['links']); $this->assertSame([ - 'id' => 1, - 'label_text' => 'offset', - ], $transformed['offset']['data'][0]); - $this->assertSame(2, $transformed['offset']['current_page']); - $this->assertSame(12, $transformed['offset']['total']); - $this->assertSame('/offset', $transformed['offset']['path']); + 'current_page' => 2, + 'first_page_url' => '/offset?tenant=one&page=1', + 'from' => 6, + 'last_page' => 3, + 'last_page_url' => '/offset?tenant=one&page=3', + 'next_page_url' => '/offset?tenant=one&page=3', + 'path' => '/offset', + 'per_page' => 5, + 'prev_page_url' => '/offset?tenant=one&page=1', + 'to' => 6, + 'total' => 12, + ], $transformed['offset']['meta']); + + $this->assertSame([['id' => 2, 'label_text' => 'cursor']], $transformed['cursor']['data']); + $this->assertSame([], $transformed['cursor']['links']); + $this->assertSame( + ['path', 'per_page', 'next_cursor', 'next_page_url', 'prev_cursor', 'prev_page_url'], + array_keys($transformed['cursor']['meta']), + ); + $this->assertSame('/cursor', $transformed['cursor']['meta']['path']); + $this->assertSame($cursor->nextCursor()?->encode(), $transformed['cursor']['meta']['next_cursor']); + $this->assertStringContainsString('tenant=one', $transformed['cursor']['meta']['next_page_url']); + $this->assertNull($transformed['cursor']['meta']['prev_cursor']); + + $this->assertSame([['id' => 4, 'label_text' => 'simple']], $transformed['simple']['data']); + $this->assertSame([], $transformed['simple']['links']); $this->assertSame([ - 'id' => 2, - 'label_text' => 'cursor', - ], $transformed['cursor']['data'][0]); - $this->assertSame('/cursor', $transformed['cursor']['path']); - $this->assertNotNull($transformed['cursor']['next_cursor']); - $this->assertStringContainsString('tenant=one', $transformed['cursor']['next_page_url']); + 'current_page' => 3, + 'current_page_url' => '/simple?page=3', + 'first_page_url' => '/simple?page=1', + 'from' => 3, + 'next_page_url' => '/simple?page=4', + 'path' => '/simple', + 'per_page' => 1, + 'prev_page_url' => '/simple?page=2', + 'to' => 3, + ], $transformed['simple']['meta']); + $this->assertSame($offsetItem, $offset->items()[0]); $this->assertSame($cursorItem, $cursor->items()[0]); + $this->assertSame($simpleItem, $simple->items()[0]); + } + + /** + * Test nested plain paginators use the configured global wrap key for their items. + */ + #[WithConfig('data.wrap', 'items')] + public function testNestedPaginatorsUseTheGlobalWrapKey(): void + { + $transformed = (new PaginatorOwnerData( + new LengthAwarePaginator([new PaginatorItemData(1, 'offset')], 1, 5), + new CursorPaginator([new PaginatorItemData(2, 'cursor')], 5), + new Paginator([new PaginatorItemData(3, 'simple')], 5), + ))->toArray(); + + $this->assertSame(['items', 'links', 'meta'], array_keys($transformed['offset'])); + $this->assertSame(['items', 'links', 'meta'], array_keys($transformed['cursor'])); + $this->assertSame(['items', 'links', 'meta'], array_keys($transformed['simple'])); } /** @@ -457,6 +511,13 @@ public function testPlainCollectionsDoNotRetainEmptyPartials(): void $this->assertFalse($this->partialDefinitionsState($collection)); $this->assertFalse($this->partialDefinitionsState($first)); $this->assertFalse($this->partialDefinitionsState($second)); + + $collection->onlyWhen('value', static fn (): bool => false); + + $this->assertSame($first, $collection[0]); + $this->assertSame([$first, $second], iterator_to_array($collection)); + $this->assertFalse($this->partialDefinitionsState($first)); + $this->assertFalse($this->partialDefinitionsState($second)); } /** @@ -801,6 +862,17 @@ public function testRetainsNonTransformableNestedAndIterableDataValues(): void $this->assertSame($nested, $data->all()['nested']); } + /** + * Test items whose declared type mixes a Data class with other types transform by value. + */ + public function testTransformsMixedDataAndValueItems(): void + { + $data = new MixedItemsData(['bug', new SimpleData('feature')]); + + $this->assertSame(['labels' => ['bug', ['value' => 'feature']]], $data->toArray()); + $this->assertSame('{"labels":["bug",{"value":"feature"}]}', $data->toJson()); + } + /** * Test a modular collectable without transformation capability remains unchanged. */ @@ -900,16 +972,48 @@ public function testPropagatesPartialsFromAllToLazyDataCollections(): void ], $returned->toArray()); } - /** - * Test nested transformation stops at the configured depth. - */ - public function testThrowsAtMaximumTransformationDepth(): void + public function testReturnsAnEmptyArrayForADataCollectionAtMaximumDepthWhenNotThrowing(): void + { + $data = new NestedCollectionOwnerData( + new DataCollection(NestedLazyData::class, [new NestedLazyData('first', 'kept')]), + ); + + $this->assertSame( + ['collection' => []], + $data->transform(TransformationContextFactory::create()->maxDepth(1, throw: false)), + ); + } + + public function testNestedPartialsOnHiddenPropertiesThrow(): void + { + $this->expectException(CannotPerformPartialOnDataField::class); + + PartialTargetData::make()->include('secret.value')->toArray(); + } + + public function testFlatUnknownPartialsSelectNothing(): void + { + $this->assertSame( + ['string' => 'World'], + PartialTargetData::make()->include('missing', 'string')->toArray(), + ); + $this->assertSame( + ['simple' => ['value' => 'Hello'], 'string' => 'World'], + PartialTargetData::make()->include('*', 'missing')->toArray(), + ); + } + + #[WithConfig('data.max_transformation_depth', 1)] + #[WithConfig('data.throw_when_max_transformation_depth_reached', false)] + public function testConfiguredDepthReturnsEmptyArraysButPersistenceStillThrows(): void { $data = new NestedData(new NestedData(new SimpleData('deep'))); - $this->expectExceptionMessageIsOrContains('Max transformation depth of 1 reached.'); + $this->assertSame(['nested' => []], $data->toArray()); - $data->transform(TransformationContextFactory::create()->maxDepth(1)); + $this->expectException(MaxTransformationDepthReached::class); + + $data->transform(TransformationContextFactory::forPersistence()); } /** @@ -1098,6 +1202,31 @@ public function __construct(public string $value) } } +class PartialTargetData extends Data +{ + /** + * Create a partial target fixture. + */ + public function __construct( + public Lazy|SimpleData $simple, + public Lazy|string $string, + #[Hidden] + public ?SimpleData $secret = null, + ) { + } + + /** + * Create the partial target with lazy values. + */ + public static function make(): self + { + return new self( + Lazy::create(static fn (): SimpleData => new SimpleData('Hello')), + Lazy::create(static fn (): string => 'World'), + ); + } +} + class DefaultPartialsData extends Data { public function __construct( @@ -1374,10 +1503,12 @@ class PaginatorOwnerData extends Data * * @param LengthAwarePaginator $offset * @param CursorPaginator $cursor + * @param Paginator $simple */ public function __construct( public LengthAwarePaginator $offset, public CursorPaginator $cursor, + public Paginator $simple, ) { } } @@ -1558,6 +1689,17 @@ public function __construct( } } +class MixedItemsData extends Data +{ + /** + * @param list $labels + */ + public function __construct( + public array $labels, + ) { + } +} + class PartialArrayItemData extends Data { /** diff --git a/tests/Data/Support/Transformation/EmptyDataResolverTest.php b/tests/Data/Support/Transformation/EmptyDataResolverTest.php new file mode 100644 index 0000000000..98756a24b9 --- /dev/null +++ b/tests/Data/Support/Transformation/EmptyDataResolverTest.php @@ -0,0 +1,254 @@ +assertEmptyPropertyValue(null, new class extends Data { + public $property; + }); + } + + public function testWillReturnNullIfThePropertyHasABasicType(): void + { + $this->assertEmptyPropertyValue(null, new class extends Data { + public int $property; + }); + + $this->assertEmptyPropertyValue(null, new class extends Data { + public bool $property; + }); + + $this->assertEmptyPropertyValue(null, new class extends Data { + public float $property; + }); + + $this->assertEmptyPropertyValue(null, new class extends Data { + public string $property; + }); + + $this->assertEmptyPropertyValue(null, new class extends Data { + public mixed $property; + }); + } + + public function testWillReturnAnArrayForCollectionTypes(): void + { + $this->assertEmptyPropertyValue([], new class extends Data { + public array $property; + }); + + $this->assertEmptyPropertyValue([], new class extends Data { + public Collection $property; + }); + + $this->assertEmptyPropertyValue([], new class extends Data { + public EloquentCollection $property; + }); + + $this->assertEmptyPropertyValue([], new class extends Data { + #[DataCollectionOf(SimpleData::class)] + public DataCollection $property; + }); + } + + public function testWillFurtherTransformResources(): void + { + $this->assertEmptyPropertyValue(['string' => null], new class extends Data { + public SimpleData $property; + }); + } + + public function testWillReturnTheBaseTypeForLazyTypes(): void + { + $this->assertEmptyPropertyValue(null, new class extends Data { + public Lazy|string $property; + }); + + $this->assertEmptyPropertyValue([], new class extends Data { + public Lazy|array $property; + }); + + $this->assertEmptyPropertyValue(['string' => null], new class extends Data { + public Lazy|SimpleData $property; + }); + } + + public function testWillReturnTheBaseTypeForLazyTypesThatCanBeNull(): void + { + $this->assertEmptyPropertyValue(null, new class extends Data { + public Lazy|string|null $property; + }); + + // Upstream comments out these two cases; they pass here because null is not counted as a type. + $this->assertEmptyPropertyValue([], new class extends Data { + public Lazy|array|null $property; + }); + + $this->assertEmptyPropertyValue(['string' => null], new class extends Data { + public Lazy|SimpleData|null $property; + }); + } + + public function testWillReturnTheBaseTypeForLazyTypesThatCanBeOptional(): void + { + $this->assertEmptyPropertyValue(null, new class extends Data { + public Lazy|string|Optional $property; + }); + + $this->assertEmptyPropertyValue([], new class extends Data { + public Lazy|array|Optional $property; + }); + + $this->assertEmptyPropertyValue(['string' => null], new class extends Data { + public Lazy|SimpleData|Optional $property; + }); + } + + public function testWillReturnTheBaseTypeForUndefinableTypes(): void + { + $this->assertEmptyPropertyValue(null, new class extends Data { + public Optional|string $property; + }); + + $this->assertEmptyPropertyValue([], new class extends Data { + public Optional|array $property; + }); + + $this->assertEmptyPropertyValue(['string' => null], new class extends Data { + public Optional|SimpleData $property; + }); + } + + public function testCannotHaveMultipleTypes(): void + { + $data = new class extends Data { + public int|string $property; + }; + + $this->expectException(DataPropertyCanOnlyHaveOneType::class); + $this->expectExceptionMessageIsOrContains($data::class . '::$property'); + + $this->assertEmptyPropertyValue(null, $data); + } + + public function testCannotHaveMultipleTypesWithALazy(): void + { + $this->expectException(DataPropertyCanOnlyHaveOneType::class); + + $this->assertEmptyPropertyValue(null, new class extends Data { + public int|string|Lazy $property; + }); + } + + public function testCannotHaveMultipleTypesWithANullableLazy(): void + { + $this->expectException(DataPropertyCanOnlyHaveOneType::class); + + $this->assertEmptyPropertyValue(null, new class extends Data { + public int|string|Lazy|null $property; + }); + } + + public function testCannotHaveMultipleTypesWithAnOptional(): void + { + $this->expectException(DataPropertyCanOnlyHaveOneType::class); + + $this->assertEmptyPropertyValue(null, new class extends Data { + public int|string|Optional $property; + }); + } + + public function testCannotHaveMultipleTypesWithANullableOptional(): void + { + $this->expectException(DataPropertyCanOnlyHaveOneType::class); + + $this->assertEmptyPropertyValue(null, new class extends Data { + public int|string|Optional|null $property; + }); + } + + public function testCanOverwriteEmptyProperties(): void + { + $this->assertEmptyPropertyValue('Hello', new class extends Data { + public string $property; + }, ['property' => 'Hello']); + } + + public function testCanUseThePropertyDefaultValue(): void + { + $this->assertEmptyPropertyValue('Hello', new class extends Data { + public string $property = 'Hello'; + }); + } + + public function testCanUseTheConstructorPropertyDefaultValue(): void + { + $this->assertEmptyPropertyValue('Hello', new class extends Data { + /** + * Create a data object with a defaulted property. + */ + public function __construct( + public string $property = 'Hello', + ) { + } + }); + } + + public function testHasSupportForMappingPropertyNames(): void + { + $this->assertEmptyPropertyValue(null, new class extends Data { + #[MapOutputName('other_property')] + public string $property; + }, propertyName: 'other_property'); + } + + public function testCanOverwriteEmptyValueToNullForAPropertyWithMultipleTypes(): void + { + $this->assertEmptyPropertyValue(null, new class extends Data { + public int|string|null $property; + }, ['property' => null]); + } + + /** + * Assert the empty value the resolver gives a property. + */ + private function assertEmptyPropertyValue( + mixed $expected, + Data $data, + array $extra = [], + string $propertyName = 'property', + ): void { + $empty = $this->app->make(EmptyDataResolver::class)->execute($data::class, $extra); + + $this->assertSame([$propertyName], array_keys($empty)); + $this->assertSame($expected, $empty[$propertyName]); + } +} diff --git a/tests/Data/Support/Transformation/PartialTreeTest.php b/tests/Data/Support/Transformation/PartialTreeTest.php index 2845588bfa..09eaeab136 100644 --- a/tests/Data/Support/Transformation/PartialTreeTest.php +++ b/tests/Data/Support/Transformation/PartialTreeTest.php @@ -11,6 +11,31 @@ class PartialTreeTest extends TestCase { + #[DataProvider('partialsProvider')] + public function testCanParsePartials(string $partial, array $expected): void + { + $this->assertSame($expected, self::paths(PartialTree::compile([$partial]))); + } + + /** + * Provide partial paths and the selections they compile to. + * + * Spatie's empty and invalid rows are rejected instead; see invalidPathProvider(). + */ + public static function partialsProvider(): array + { + return [ + 'root property' => ['name', ['name']], + 'root multi-property' => ['{name, age}', ['name', 'age']], + 'root star' => ['*', ['*']], + 'nested property' => ['struct.name', ['struct.name']], + 'nested multi-property' => ['struct.{name, age}', ['struct.name', 'struct.age']], + 'nested star' => ['struct.*', ['struct.*']], + ]; + } + + // REMOVED: Spatie's pointer system tests. Paths compile into an immutable tree navigated with child(), so there is no pointer to advance or roll back. + /** * Test paths compile into one reusable nested selection. */ @@ -28,6 +53,7 @@ public function testCompilesNestedGroupedAndWildcardPaths(): void $this->assertTrue($tree->contains('songs')); $this->assertFalse($tree->selects('songs')); $this->assertFalse($tree->selects('year')); + $this->assertSame(['artist', 'songs'], $tree->nestedProperties); $artist = $tree->child('artist'); @@ -36,6 +62,8 @@ public function testCompilesNestedGroupedAndWildcardPaths(): void $this->assertTrue($artist->selects('email')); $this->assertTrue($artist->selects('role')); $this->assertFalse($artist->selects('id')); + $this->assertSame([], $artist->nestedProperties); + $this->assertNull($artist->child('name')->child('first')); $songs = $tree->child('songs'); @@ -83,6 +111,11 @@ public function testRetainsExactSelectionAndPropagatingWildcardProvenance(): voi $this->assertTrue($all->selects('anything')); $this->assertTrue($all->child('artist')->all); $this->assertTrue($all->child('artist')->selects('name')); + $this->assertSame(['artist'], $all->nestedProperties); + + // An inherited * ends the selection at the child; only the child's own * continues it. + $this->assertSame([], PartialTree::compile(['*', 'artist'])->nestedProperties); + $this->assertSame(['artist'], PartialTree::compile(['*', 'artist.*'])->nestedProperties); $unlisted = $all->child('unlisted'); @@ -108,6 +141,7 @@ public function testMergesCompiledSelections(): void $this->assertSame(['name', 'email'], array_keys($merged->child('artist')->children)); $this->assertTrue($merged->child('songs')->all); $this->assertTrue($merged->child('profile')->selects('name')); + $this->assertSame(['artist', 'songs', 'profile'], $merged->nestedProperties); $this->assertSame($tree, $tree->merge(null)); } @@ -129,6 +163,28 @@ public function testMergesWildcardAndExplicitSelectionsInEitherOrder(): void $this->assertSame(['name'], array_keys($merged->child('artist')->children)); $this->assertTrue($merged->child('unlisted')->all); } + + $ownWildcard = PartialTree::compile(['artist.*']); + + $this->assertNotNull($ownWildcard); + + foreach ([$all->merge($ownWildcard), $ownWildcard->merge($all)] as $merged) { + $this->assertSame(['artist'], $merged->nestedProperties); + } + } + + /** + * Test numeric segments, which select array items, compile and merge. + */ + public function testCompilesAndMergesNumericSegments(): void + { + $tree = PartialTree::compile(['tags.0', 'songs.0.name']); + $other = PartialTree::compile(['tags.1']); + + $this->assertNotNull($tree); + $this->assertNotNull($other); + $this->assertSame(['0'], $tree->child('songs')->nestedProperties); + $this->assertSame([0, 1], array_keys($tree->merge($other)->child('tags')->children)); } /** @@ -157,12 +213,32 @@ public static function invalidPathProvider(): array { return [ 'empty' => [''], + 'nested property on all' => ['*.name'], + 'nested property on multi-property' => ['{name, age}.name'], 'empty segment' => ['artist..name'], - 'wildcard suffix' => ['artist.*.name'], 'partial wildcard' => ['artist.na*'], 'unclosed group' => ['artist.{name,email'], 'empty group field' => ['artist.{name,}'], - 'nested group' => ['artist.{name,email}.value'], ]; } + + /** + * Get the selected paths of a compiled tree. + * + * @return list + */ + private static function paths(PartialTree $tree, string $prefix = ''): array + { + $paths = $tree->all ? [$prefix . '*'] : []; + + foreach ($tree->children as $property => $child) { + if ($child->selected) { + $paths[] = $prefix . $property; + } + + array_push($paths, ...self::paths($child, $prefix . $property . '.')); + } + + return $paths; + } } diff --git a/tests/Data/Support/Transformation/TransformationContextFactoryTest.php b/tests/Data/Support/Transformation/TransformationContextFactoryTest.php index 69a32f8a60..e8bf6ef59d 100644 --- a/tests/Data/Support/Transformation/TransformationContextFactoryTest.php +++ b/tests/Data/Support/Transformation/TransformationContextFactoryTest.php @@ -8,9 +8,12 @@ use Hypervel\Data\Data; use Hypervel\Data\DataServiceProvider; use Hypervel\Data\Support\Partials\PartialDefinition; +use Hypervel\Data\Support\Transformation\TransformationContext; use Hypervel\Data\Support\Transformation\TransformationContextFactory; use Hypervel\Data\Support\Wrapping\WrapExecutionType; use Hypervel\Testbench\TestCase; +use Hypervel\Tests\Data\Fixtures\SimpleData; +use Hypervel\Tests\Data\Fixtures\Transformers\StringToUpperTransformer; use stdClass; class TransformationContextFactoryTest extends TestCase @@ -23,6 +26,107 @@ protected function getPackageProviders(Application $app): array return [DataServiceProvider::class]; } + public function testCanCreateATransformationContext(): void + { + $context = TransformationContextFactory::create()->get( + SimpleData::from('Hello World') + ); + + $this->assertInstanceOf(TransformationContext::class, $context); + $this->assertTrue($context->transformValues); + $this->assertTrue($context->mapPropertyNames); + $this->assertSame(WrapExecutionType::Disabled, $context->wrapExecutionType); + $this->assertSame([], $context->transformers); + $this->assertSame(0, $context->depth); + $this->assertNull($context->maxDepth); + $this->assertTrue($context->throwWhenMaxDepthReached); + } + + public function testCanDisableValueTransformation(): void + { + $context = TransformationContextFactory::create() + ->withoutValueTransformation() + ->get(SimpleData::from('Hello World')); + + $this->assertFalse($context->transformValues); + } + + public function testCanEnableValueTransformation(): void + { + $context = TransformationContextFactory::create() + ->withValueTransformation() + ->get(SimpleData::from('Hello World')); + + $this->assertTrue($context->transformValues); + } + + public function testCanDisablePropertyNameMapping(): void + { + $context = TransformationContextFactory::create() + ->withoutPropertyNameMapping() + ->get(SimpleData::from('Hello World')); + + $this->assertFalse($context->mapPropertyNames); + } + + public function testCanEnablePropertyNameMapping(): void + { + $context = TransformationContextFactory::create() + ->withPropertyNameMapping() + ->get(SimpleData::from('Hello World')); + + $this->assertTrue($context->mapPropertyNames); + } + + public function testCanDisableWrapping(): void + { + $context = TransformationContextFactory::create() + ->withoutWrapping() + ->get(SimpleData::from('Hello World')); + + $this->assertSame(WrapExecutionType::Disabled, $context->wrapExecutionType); + } + + public function testCanEnableWrapping(): void + { + $context = TransformationContextFactory::create() + ->withWrapping() + ->get(SimpleData::from('Hello World')); + + $this->assertSame(WrapExecutionType::Enabled, $context->wrapExecutionType); + } + + public function testCanSetACustomWrapExecutionType(): void + { + $context = TransformationContextFactory::create() + ->withWrapExecutionType(WrapExecutionType::Enabled) + ->get(SimpleData::from('Hello World')); + + $this->assertSame(WrapExecutionType::Enabled, $context->wrapExecutionType); + } + + public function testCanAddACustomTransformers(): void + { + $factory = TransformationContextFactory::create() + ->withTransformer('string', StringToUpperTransformer::class); + $context = $factory->get(SimpleData::from('Hello World')); + + // Transformers are keyed by the declared type they handle, so the string transformer is found for a string value. + $this->assertSame(['string' => StringToUpperTransformer::class], $context->transformers); + $this->assertSame(['string' => 'HELLO WORLD'], SimpleData::from('Hello World')->transform($factory)); + } + + public function testCanSetAMaxTransformationDepth(): void + { + $context = TransformationContextFactory::create() + ->maxDepth(4) + ->get(SimpleData::from('Hello World')); + + $this->assertSame(4, $context->maxDepth); + $this->assertSame(0, $context->depth); + $this->assertTrue($context->throwWhenMaxDepthReached); + } + /** * Test the fluent factory builds one immutable operation context. */ @@ -47,6 +151,7 @@ public function testBuildsConfiguredTransformationContext(): void $this->assertFalse($context->transformValues); $this->assertFalse($context->mapPropertyNames); $this->assertSame(4, $context->maxDepth); + $this->assertTrue($context->throwWhenMaxDepthReached); $this->assertTrue($context->hasPartials()); $this->assertTrue($context->include?->child('profile')?->selects('avatar')); $this->assertTrue($context->exclude?->child('profile')?->selects('secret')); @@ -68,6 +173,17 @@ public function testBuildsConfiguredTransformationContext(): void $this->assertNull($nested['include'][1]->condition); } + public function testCanSetAMaxTransformationDepthWithoutFailing(): void + { + $context = TransformationContextFactory::create() + ->maxDepth(4, throw: false) + ->get(new stdClass); + + $this->assertSame(4, $context->maxDepth); + $this->assertSame(0, $context->depth); + $this->assertFalse($context->throwWhenMaxDepthReached); + } + /** * Test each static factory call resolves a fresh transient instance. */ @@ -93,7 +209,7 @@ public function testPersistenceFactoryDerivesACompleteConstructableView(): void ->withPropertyNameMapping() ->withWrapping() ->only('name') - ->maxDepth(5) + ->maxDepth(5, throw: false) ->get($data); $this->assertTrue($context->transformValues); diff --git a/tests/Data/Support/Validation/CompiledValidationTest.php b/tests/Data/Support/Validation/CompiledValidationTest.php index 51c157c4bc..c6c4cdda5f 100644 --- a/tests/Data/Support/Validation/CompiledValidationTest.php +++ b/tests/Data/Support/Validation/CompiledValidationTest.php @@ -7,6 +7,7 @@ use Hypervel\Data\Support\Validation\CompiledValidation; use Hypervel\Data\Support\Validation\ValidationPath; use Hypervel\Tests\TestCase; +use Hypervel\Validation\ValidationData; use stdClass; class CompiledValidationTest extends TestCase @@ -26,7 +27,7 @@ public function testRestoresPreservedValuesByExactPath(): void $payload = $compiled->restorePreservedValues( ['items' => ['other' => 'value']], - ['items' => ['first.item' => $preserved]], + ValidationData::encodeKeys(['items' => ['first.item' => $preserved]]), ); $this->assertSame($preserved, $payload['items']['first.item']); @@ -77,14 +78,14 @@ public function testRestoresPreservedValuesByWildcardPath(): void '*' => ['id' => 4], ], ], - [ + ValidationData::encodeKeys([ 'items' => [ 0 => ['id' => 1], 1 => ['id' => 2, 'secret' => 'two'], 'literal.item' => ['id' => 3, 'secret' => null], '*' => ['id' => 4, 'secret' => 'star'], ], - ], + ]), ); $this->assertArrayNotHasKey('secret', $payload['items'][0]); @@ -113,12 +114,12 @@ public function testRestoresPreservedValuesByLiteralAsteriskPath(): void 'other' => ['id' => 2], ], ], - [ + ValidationData::encodeKeys([ 'items' => [ '*' => ['id' => 1, 'secret' => 'star'], 'other' => ['id' => 2, 'secret' => 'other'], ], - ], + ]), ); $this->assertSame('star', $payload['items']['*']['secret']); diff --git a/tests/Data/Support/Validation/Constraints/DatabaseConstraintTest.php b/tests/Data/Support/Validation/Constraints/DatabaseConstraintTest.php index 7c44457555..ad64f0d52d 100644 --- a/tests/Data/Support/Validation/Constraints/DatabaseConstraintTest.php +++ b/tests/Data/Support/Validation/Constraints/DatabaseConstraintTest.php @@ -11,9 +11,11 @@ use Hypervel\Data\Support\Validation\Constraints\WhereNotNullConstraint; use Hypervel\Data\Support\Validation\Constraints\WhereNullConstraint; use Hypervel\Data\Support\Validation\References\ExternalReference; +use Hypervel\Database\Query\Builder; use Hypervel\Tests\TestCase; use Hypervel\Validation\Rules\Exists; use Hypervel\Validation\Rules\Unique; +use Mockery as m; use PHPUnit\Framework\Attributes\DataProvider; class DatabaseConstraintTest extends TestCase @@ -54,11 +56,21 @@ public static function databaseRules(): iterable #[DataProvider('databaseRuleObjects')] public function testAppliesCallbackConstraints(Exists|Unique $rule): void { - (new WhereConstraint(static fn (): null => null))->apply($rule); + $where = static fn (): null => null; + + (new WhereConstraint($where))->apply($rule); (new WhereInConstraint('status', ['active', 'pending']))->apply($rule); (new WhereNotInConstraint('role', ['admin', 'owner']))->apply($rule); + [$callback, $whereIn, $whereNotIn] = $rule->queryCallbacks(); + $query = m::mock(Builder::class); + $query->shouldReceive('whereIn')->once()->with('status', ['active', 'pending'])->andReturnSelf(); + $query->shouldReceive('whereNotIn')->once()->with('role', ['admin', 'owner'])->andReturnSelf(); + $this->assertCount(3, $rule->queryCallbacks()); + $this->assertSame($where, $callback); + $whereIn($query); + $whereNotIn($query); } /** diff --git a/tests/Data/Support/Validation/DataValidatorTest.php b/tests/Data/Support/Validation/DataValidatorTest.php index c7551aee90..ecd78617f6 100644 --- a/tests/Data/Support/Validation/DataValidatorTest.php +++ b/tests/Data/Support/Validation/DataValidatorTest.php @@ -4,18 +4,33 @@ namespace Hypervel\Tests\Data\Support\Validation; +use Attribute; +use Closure; use Hypervel\Auth\Access\AuthorizationException; use Hypervel\Auth\Access\Response as AuthorizationResponse; use Hypervel\Container\Attributes\Config; +use Hypervel\Context\CoroutineContext; use Hypervel\Contracts\Foundation\Application; use Hypervel\Contracts\Routing\Registrar; +use Hypervel\Contracts\Validation\ValidationRule as ValidationRuleContract; +use Hypervel\Data\Attributes\Computed; use Hypervel\Data\Attributes\DataCollectionOf; use Hypervel\Data\Attributes\MapInputName; use Hypervel\Data\Attributes\MergeValidationRules; use Hypervel\Data\Attributes\PropertyForMorph; +use Hypervel\Data\Attributes\Validation\ArrayType; +use Hypervel\Data\Attributes\Validation\Confirmed; +use Hypervel\Data\Attributes\Validation\CustomValidationAttribute; use Hypervel\Data\Attributes\Validation\Distinct; +use Hypervel\Data\Attributes\Validation\Enum as EnumAttribute; +use Hypervel\Data\Attributes\Validation\ExcludeIf; +use Hypervel\Data\Attributes\Validation\Max; +use Hypervel\Data\Attributes\Validation\Min; use Hypervel\Data\Attributes\Validation\Required; use Hypervel\Data\Attributes\Validation\RequiredUnless; +use Hypervel\Data\Attributes\Validation\RequiredWith; +use Hypervel\Data\Attributes\Validation\Rule; +use Hypervel\Data\Attributes\Validation\Sometimes; use Hypervel\Data\Attributes\Validation\StringType; use Hypervel\Data\Attributes\WithoutValidation; use Hypervel\Data\Contracts\PropertyMorphableData; @@ -24,10 +39,15 @@ use Hypervel\Data\DataServiceProvider; use Hypervel\Data\Dto; use Hypervel\Data\Exceptions\CannotBuildValidationRule; +use Hypervel\Data\Exceptions\CannotCreateAbstractClass; +use Hypervel\Data\Exceptions\CannotCreateData; use Hypervel\Data\Normalizers\Normalizer; use Hypervel\Data\Optional; use Hypervel\Data\Resource; +use Hypervel\Data\RuleInferrers\RuleInferrer; use Hypervel\Data\Support\DataProperty; +use Hypervel\Data\Support\Validation\PropertyRules; +use Hypervel\Data\Support\Validation\References\ContainerReference; use Hypervel\Data\Support\Validation\ValidationContext; use Hypervel\Data\Support\Validation\ValidationPath; use Hypervel\Database\Eloquent\Model; @@ -39,14 +59,19 @@ use Hypervel\Http\Request; use Hypervel\Support\Collection; use Hypervel\Support\LazyCollection; +use Hypervel\Testbench\Attributes\WithConfig; use Hypervel\Testbench\TestCase; +use Hypervel\Tests\Data\Fixtures\SimpleData; use Hypervel\Validation\Factory as ValidationFactory; +use Hypervel\Validation\Rules\Enum as EnumRule; use Hypervel\Validation\ValidationException; use Hypervel\Validation\Validator; use PHPUnit\Framework\Attributes\DataProvider; use ReflectionMethod; use Symfony\Component\HttpKernel\Exception\HttpException; +use function Hypervel\Coroutine\parallel; + class DataValidatorTest extends TestCase { /** @@ -62,9 +87,10 @@ protected function getPackageProviders(Application $app): array */ public function testOnlyRequestsValidationStrategyKeepsArrayCreationLean(): void { - $arrayData = ValidatedDataFixture::from(['id' => 'invalid']); + // PHP converts '1e3', while the integer rule would reject it. + $arrayData = ValidatedDataFixture::from(['id' => '1e3']); - $this->assertSame(0, $arrayData->id); + $this->assertSame(1000, $arrayData->id); $this->expectException(ValidationException::class); @@ -76,12 +102,12 @@ public function testOnlyRequestsValidationStrategyKeepsArrayCreationLean(): void */ public function testBaseClassesShareRequestOnlyValidationByDefault(): void { - $this->assertSame(0, ValidatedDtoFixture::from(['id' => 'invalid'])->id); - $this->assertSame(0, ValidatedResourceFixture::from(['id' => 'invalid'])->id); + $this->assertSame(1000, ValidatedDtoFixture::from(['id' => '1e3'])->id); + $this->assertSame(1000, ValidatedResourceFixture::from(['id' => '1e3'])->id); foreach ([ValidatedDtoFixture::class, ValidatedResourceFixture::class] as $class) { try { - $class::from(Request::create('/', 'POST', ['id' => 'invalid'])); + $class::from(Request::create('/', 'POST', ['id' => '1e3'])); $this->fail("Expected {$class} Request validation to fail."); } catch (ValidationException $exception) { $this->assertArrayHasKey('id', $exception->errors()); @@ -89,18 +115,6 @@ public function testBaseClassesShareRequestOnlyValidationByDefault(): void } } - /** - * Test a factory can validate non-Request payloads. - */ - public function testFactoryCanAlwaysValidateArrayPayloads(): void - { - $this->expectException(ValidationException::class); - - ValidatedDataFixture::factory() - ->alwaysValidate() - ->from(['id' => 'invalid']); - } - /** * Test validation-only mode returns uncast validated input. */ @@ -122,20 +136,409 @@ public function testValidateAndCreateUsesTheSameRulesBeforeCasting(): void } /** - * Test inferred rules follow the declared presence and primitive types. + * Test a confirmation rule reads its undeclared confirmation field. + */ + public function testConfirmedRuleReadsItsUndeclaredConfirmationField(): void + { + $data = ConfirmedPasswordDataFixture::validateAndCreate([ + 'password' => 'secret123', + 'password_confirmation' => 'secret123', + ]); + + $this->assertSame('secret123', $data->password); + + try { + ConfirmedPasswordDataFixture::validateAndCreate([ + 'password' => 'secret123', + 'password_confirmation' => 'different', + ]); + $this->fail('Expected a mismatched confirmation to fail validation.'); + } catch (ValidationException $exception) { + $this->assertArrayHasKey('password', $exception->errors()); + } + } + + /** + * Test rules may reference undeclared input that the validated payload omits. + */ + public function testRulesReferenceUndeclaredInputWithoutReturningIt(): void + { + try { + ConditionalNameDataFixture::validate(['mode' => 'admin']); + $this->fail('Expected the undeclared condition to require the name.'); + } catch (ValidationException $exception) { + $this->assertArrayHasKey('name', $exception->errors()); + } + + $this->assertSame( + ['name' => 'Taylor'], + ConditionalNameDataFixture::validate(['mode' => 'admin', 'name' => 'Taylor']), + ); + } + + /** + * Test undeclared input beside a dot-mapped property remains available to its rules. + */ + public function testUndeclaredInputBesideADotMappedPropertyRemainsAvailable(): void + { + try { + MappedConditionalNameDataFixture::validate(['profile' => ['mode' => 'strict']]); + $this->fail('Expected the sibling condition to require the mapped name.'); + } catch (ValidationException $exception) { + $this->assertArrayHasKey('profile.name', $exception->errors()); + } + + $this->assertSame( + ['profile' => ['name' => 'Taylor']], + MappedConditionalNameDataFixture::validate(['profile' => ['mode' => 'strict', 'name' => 'Taylor']]), + ); + } + + public function testDataCollectionsMustBePresentButMayBeEmpty(): void + { + $expected = [ + 'items' => ['present', 'array'], + 'nullableItems' => ['nullable', 'array'], + 'optionalItems' => ['sometimes', 'array'], + 'requiredItems' => ['array', 'required'], + 'plain' => ['required', 'array'], + ]; + + $this->assertSame($expected, PresentCollectionDataFixture::getValidationRules([])); + + $valid = ['items' => [], 'requiredItems' => [['string' => 'a']], 'plain' => ['a']]; + + $this->assertSame($valid, PresentCollectionDataFixture::validate($valid)); + + foreach ([ + 'items' => ['requiredItems' => [['string' => 'a']], 'plain' => ['a']], + 'requiredItems' => ['items' => [], 'requiredItems' => [], 'plain' => ['a']], + 'plain' => ['items' => [], 'requiredItems' => [['string' => 'a']], 'plain' => []], + ] as $errorKey => $payload) { + try { + PresentCollectionDataFixture::validate($payload); + $this->fail("Expected a validation error for [{$errorKey}]."); + } catch (ValidationException $exception) { + $this->assertSame([$errorKey], array_keys($exception->errors())); + } + } + } + + #[WithConfig('data.rule_inferrers', [MaxStringRuleInferrer::class])] + public function testConfiguredRuleInferrersKeepDataCollectionPresence(): void + { + $rules = PresentCollectionDataFixture::getValidationRules([]); + + $this->assertSame(['present', 'array'], $rules['items']); + $this->assertSame(['array', 'required'], $rules['requiredItems']); + } + + public function testClassRulesMergeWithOrReplaceDataCollectionPresence(): void + { + $this->assertSame( + ['present', 'array', 'required'], + MergedRequiredCollectionDataFixture::getValidationRules([])['items'], + ); + + try { + MergedRequiredCollectionDataFixture::validate(['items' => []]); + $this->fail('Expected the merged required rule to reject an empty collection.'); + } catch (ValidationException $exception) { + $this->assertSame(['items'], array_keys($exception->errors())); + } + + $this->assertSame(['array'], ReplacedCollectionRulesDataFixture::getValidationRules([])['items']); + $this->assertSame([], ReplacedCollectionRulesDataFixture::validate([])); + } + + public function testUnionValuesUseTheTypeRuleOfTheTypeThatHoldsThem(): void + { + $data = UnionTypeRuleDataFixture::validateAndCreate([ + 'stringOrData' => 'Hello', + 'intOrData' => 10, + 'container' => new Collection(['a']), + 'collectionOrString' => 'x', + ]); + + $this->assertSame('Hello', $data->stringOrData); + $this->assertSame(10, $data->intOrData); + $this->assertSame(['a'], $data->container->all()); + $this->assertSame('x', $data->collectionOrString); + + // The integer rule keeps the size rule numeric, and an object normalized for the data type keeps its rules. + foreach ([ + 'intOrData' => ['intOrData' => 3], + 'arrayOrData.string' => ['arrayOrData' => (object) ['other' => 'value']], + ] as $errorKey => $payload) { + try { + UnionTypeRuleDataFixture::validateAndCreate($payload); + $this->fail("Expected a validation error for [{$errorKey}]."); + } catch (ValidationException $exception) { + $this->assertSame([$errorKey], array_keys($exception->errors())); + } + } + } + + /** + * Test a backed-enum property infers the enum rule, so invalid request input fails validation instead of the cast. + */ + public function testBackedEnumPropertiesInferTheEnumRule(): void + { + $this->assertEquals( + ['required', new EnumRule(ValidationStatusEnumFixture::class)], + EnumValidatedDataFixture::getValidationRules([])['status'], + ); + + try { + EnumValidatedDataFixture::from(Request::create('/', 'POST', ['status' => 'invalid', 'priority' => '1'])); + $this->fail('Expected the invalid enum value to fail validation.'); + } catch (ValidationException $exception) { + $this->assertSame(['status'], array_keys($exception->errors())); + } + + // Integral numeric strings select an integer-backed case, as the enum cast accepts them. + foreach (['1', '1.0'] as $priority) { + $data = EnumValidatedDataFixture::from( + Request::create('/', 'POST', ['status' => 'active', 'priority' => $priority]), + ); + + $this->assertSame(ValidationStatusEnumFixture::Active, $data->status); + $this->assertSame(ValidationPriorityEnumFixture::High, $data->priority); + } + } + + /** + * Test a declared enum rule, with its case restrictions, replaces the inferred one. + */ + public function testDeclaredEnumRulesReplaceTheInferredEnumRule(): void + { + $rules = RestrictedEnumDataFixture::getValidationRules([])['status']; + + $this->assertCount(1, array_filter($rules, static fn (mixed $rule): bool => $rule instanceof EnumRule)); + + try { + RestrictedEnumDataFixture::validate(['status' => 'inactive']); + $this->fail('Expected the excluded case to fail validation.'); + } catch (ValidationException $exception) { + $this->assertSame(['status'], array_keys($exception->errors())); + } + } + + /** + * Test identical dynamic items keep one wildcard enum rule, while different explicit restrictions stay per item. + */ + public function testEnumRulesKeepUniformCollectionsWildcard(): void + { + $rules = EnumItemsParentDataFixture::getValidationRules([ + 'items' => [['status' => 'active'], ['status' => 'inactive']], + ]); + + $this->assertArrayHasKey('items.*.status', $rules); + $this->assertArrayNotHasKey('items.0.status', $rules); + + $rules = EnumItemsParentDataFixture::getValidationRules([ + 'items' => [ + ['status' => 'active', 'only' => 'active'], + ['status' => 'inactive', 'only' => 'inactive'], + ], + ]); + + $this->assertArrayHasKey('items.0.status', $rules); + $this->assertArrayHasKey('items.1.status', $rules); + } + + /** + * Test configured rule inferrers adjust attribute and inferred rules. + */ + #[WithConfig('data.rule_inferrers', [MaxStringRuleInferrer::class, OptionalNicknameRuleInferrer::class])] + public function testConfiguredRuleInferrersAdjustTheInferredRules(): void + { + $rules = RuleInferrerDataFixture::getValidationRules([]); + + $this->assertSame(['required', 'string', 'max:255'], $rules['name']); + $this->assertSame(['string', 'max:255', 'sometimes'], $rules['nickname']); + $this->assertSame(['required', 'string', 'max:20'], $rules['code']); + $this->assertSame(['required', 'array:id'], $rules['meta']); + $this->assertSame(['required', 'integer'], $rules['age']); + // A Rule attribute's string rules reach the inferrers as typed rules. + $this->assertSame(['required', 'string', 'max:255'], $rules['ruled']); + } + + /** + * Test declared attributes replace the inferred rule of their type without configured inferrers too. + */ + public function testDeclaredRulesReplaceTheInferredRuleOfTheirType(): void + { + $rules = RuleInferrerDataFixture::getValidationRules([]); + + $this->assertSame(['required', 'string', 'max:20'], $rules['code']); + $this->assertSame(['required', 'array:id'], $rules['meta']); + $this->assertSame(['required', 'string'], $rules['ruled']); + } + + /** + * Test merged class presence rules replace a requirement left by the inferrers. + */ + #[WithConfig('data.rule_inferrers', [MaxStringRuleInferrer::class])] + public function testMergedClassPresenceRulesStillReplaceAnInferredRequirement(): void + { + $rules = MergedRuleInferrerDataFixture::getValidationRules([]); + + $this->assertSame(['string', 'max:255', 'required_with:other'], $rules['name']); + } + + /** + * Test rule inferrers receive each collection item's payload. + */ + #[WithConfig('data.rule_inferrers', [PayloadLengthRuleInferrer::class])] + public function testRuleInferrersReceiveEachCollectionItemPayload(): void + { + $rules = RuleInferrerParentDataFixture::getValidationRules([ + 'items' => [['name' => 'Taylor'], ['name' => 'Swift', 'long' => true]], + ]); + + $this->assertSame(['required', 'string', 'max:5'], $rules['items.0.name']); + $this->assertSame(['required', 'string', 'max:100'], $rules['items.1.name']); + $this->assertSame([], $rules['items.*.name']); + } + + /** + * Test rule inferrers see each collection item's own input path, while uniform items keep one wildcard rule. + */ + #[WithConfig('data.rule_inferrers', [RecordingContextRuleInferrer::class])] + public function testRuleInferrersReceiveEachCollectionItemPath(): void + { + RecordingContextRuleInferrer::$contexts = []; + + $rules = RuleInferrerParentDataFixture::getValidationRules([ + 'items' => [['name' => 'Taylor'], ['name' => 'Swift']], + ]); + + $this->assertSame( + [ + 'items' => [''], + 'name' => ['items.0', 'items.1'], + 'long' => ['items.0', 'items.1'], + ], + array_map( + static fn (array $contexts): array => array_map( + static fn (ValidationContext $context): string => $context->path->get(), + $contexts, + ), + RecordingContextRuleInferrer::$contexts, + ), + ); + $this->assertArrayHasKey('items.*.name', $rules); + $this->assertArrayNotHasKey('items.0.name', $rules); + } + + /** + * Test rule inferrers for a missing or null child and an empty collection's item template receive no input. + */ + #[WithConfig('data.rule_inferrers', [RecordingContextRuleInferrer::class])] + public function testRuleInferrersReceiveNoInputForUnobservedNodes(): void + { + foreach ([[], ['child' => null]] as $child) { + RecordingContextRuleInferrer::$contexts = []; + $payload = [...$child, 'children' => []]; + + UnreadableParentDataFixture::getValidationRules($payload); + + $this->assertSame( + [['child', [], $payload], ['children.*', [], $payload]], + array_map( + static fn (ValidationContext $context): array => [ + $context->path->get(), + $context->payload, + $context->fullPayload, + ], + RecordingContextRuleInferrer::$contexts['name'], + ), + ); + } + } + + /** + * Test scoped rule inferrers are resolved for each compilation. + */ + #[WithConfig('data.rule_inferrers', [ScopedMaxRuleInferrer::class])] + public function testScopedRuleInferrersFollowTheCurrentCoroutine(): void + { + $this->app->scoped( + ScopedMaxRuleInferrer::class, + static fn (): ScopedMaxRuleInferrer => new ScopedMaxRuleInferrer( + CoroutineContext::get('__data.test.max'), + ), + ); + + $nameRules = static function (int $max): array { + CoroutineContext::set('__data.test.max', $max); + usleep(5000); + + return RuleInferrerDataFixture::getValidationRules([])['name']; + }; + + [$first, $second] = parallel([ + static fn (): array => $nameRules(10), + static fn (): array => $nameRules(20), + ]); + + $this->assertSame(['required', 'string', 'max:10'], $first); + $this->assertSame(['required', 'string', 'max:20'], $second); + } + + /** + * Test a rule removed by an inferrer never resolves its references. + */ + #[WithConfig('data.rule_inferrers', [RemoveMaxRuleInferrer::class])] + public function testRuleInferrersRemoveRulesBeforeTheirReferencesResolve(): void + { + $rules = UnboundReferenceDataFixture::getValidationRules([]); + + $this->assertSame(['required', 'string'], $rules['name']); + } + + /** + * Test rules that survive the inferrers resolve their values once. + */ + #[WithConfig('data.rule_inferrers', [MaxStringRuleInferrer::class])] + public function testRulesSurvivingInferrersResolveOnce(): void + { + $resolutions = 0; + $this->app->bind('counted-limit', static function () use (&$resolutions): int { + ++$resolutions; + + return 10; + }); + CountingRuleAttribute::$evaluations = 0; + + $rules = CountedReferenceDataFixture::getValidationRules([]); + + $this->assertSame(['required', 'string', 'max:10', 'alpha'], $rules['name']); + $this->assertSame(1, $resolutions); + $this->assertSame(1, CountingRuleAttribute::$evaluations); + } + + /** + * Test class rules and rule inferrers receive undeclared input at every level. */ - public function testExposesInferredValidationRules(): void + #[WithConfig('data.rule_inferrers', [RecordingContextRuleInferrer::class])] + public function testValidationContextsIncludeUndeclaredInput(): void { - $rules = ValidatedDataFixture::getValidationRules(['id' => 1]); + RecordingContextRuleInferrer::$contexts = []; + ContextParentDataFixture::$contexts = []; + $payload = ['mode' => 'admin', 'child' => ['kind' => 'primary', 'value' => 'a']]; + + ContextParentDataFixture::getValidationRules($payload); - $this->assertSame(['required', 'integer'], $rules['id']); - $this->assertSame(['nullable', 'string'], $rules['nickname']); - $this->assertSame(['sometimes', 'string'], $rules['note']); - $this->assertSame(['string'], $rules['label']); + $this->assertSame([$payload, $payload], ContextParentDataFixture::$contexts['parent']); + $this->assertSame([$payload['child'], $payload], ContextParentDataFixture::$contexts['child']); + [$valueContext] = RecordingContextRuleInferrer::$contexts['value']; + $this->assertSame([$payload['child'], $payload], [$valueContext->payload, $valueContext->fullPayload]); } /** - * Test mixed collection wire choices produce exact mapped error paths. + * Test validated collection items read only the mapped wire key and report errors at its path. */ public function testValidatesNestedDataCollectionsWithMappedWireKeys(): void { @@ -145,13 +548,52 @@ public function testValidatesNestedDataCollectionsWithMappedWireKeys(): void ->from([ 'children' => [ ['profile' => ['name' => 123]], - ['name' => 456], + // Under the PHP property name, the short value is neither read nor validated. + ['name' => 'Tay'], ], ]); $this->fail('Expected nested validation to fail.'); } catch (ValidationException $exception) { - $this->assertArrayHasKey('children.0.profile.name', $exception->errors()); - $this->assertArrayHasKey('children.1.name', $exception->errors()); + $this->assertSame(['children.0.profile.name'], array_keys($exception->errors())); + } + } + + /** + * Test validated sources read only the mapped name, so a later source's PHP property name is not read. + */ + public function testValidatedSourcesReadOnlyTheMappedName(): void + { + $data = MappedValueDataFixture::factory() + ->alwaysValidate() + ->from(['wire' => 'first'], ['value' => 'second']); + + $this->assertSame('first', $data->value); + + // Without validation, the PHP property name remains a fallback, as in Spatie. + $this->assertSame('second', MappedValueDataFixture::from(['value' => 'second'])->value); + } + + /** + * Test an Optional mapped property ignores validated input under its PHP property name. + */ + public function testValidatedOptionalMappedPropertiesIgnoreThePropertyName(): void + { + $data = OptionalMappedValueDataFixture::validateAndCreate(['value' => 'ignored']); + + $this->assertInstanceOf(Optional::class, $data->value); + $this->assertSame([], $data->toArray()); + } + + /** + * Test strict validation reports a PHP property name sent in place of the mapped name. + */ + public function testFailOnUnknownFieldsRejectsThePropertyNameOfAMappedProperty(): void + { + try { + StrictMappedValueDataFixture::validateAndCreate(['value' => 'first']); + $this->fail('Expected the PHP property name to fail validation.'); + } catch (ValidationException $exception) { + $this->assertSame(['wire', 'value'], array_keys($exception->errors())); } } @@ -162,7 +604,7 @@ public function testValidationMaterializesLazyCollectionForArrayProperty(): void { $data = ValidatedParentDataFixture::validateAndCreate([ 'children' => LazyCollection::make([ - ['name' => 'Taylor'], + ['profile' => ['name' => 'Taylor']], ]), ]); @@ -177,7 +619,7 @@ public function testValidationRebuildsDeclaredLazyCollection(): void { $data = ValidatedLazyParentDataFixture::validateAndCreate([ 'children' => LazyCollection::make([ - ['name' => 'Taylor'], + ['profile' => ['name' => 'Taylor']], ]), ]); @@ -194,8 +636,8 @@ public function testValidationRebuildsDeclaredLazyCollection(): void public function testRuleIntrospectionMaterializesLazyCollections(): void { $children = [ - ['name' => 'Taylor'], - ['name' => 'Abigail'], + ['profile' => ['name' => 'Taylor']], + ['profile' => ['name' => 'Abigail']], ]; $arrayRules = ValidatedLazyParentDataFixture::getValidationRules([ 'children' => $children, @@ -205,25 +647,27 @@ public function testRuleIntrospectionMaterializesLazyCollections(): void ]); $this->assertSame($arrayRules, $lazyRules); - $this->assertArrayHasKey('children.*.name', $lazyRules); + $this->assertArrayHasKey('children.*.profile.name', $lazyRules); } /** - * Test class wildcard rules follow each observed collection wire path. + * Test a class wildcard rule keyed by PHP property names applies at each item's mapped wire path. */ - public function testTranslatesClassWildcardRulesAcrossMixedWireKeys(): void + public function testTranslatesClassWildcardRulesToMappedWirePaths(): void { try { ValidatedParentDataFixture::validateAndCreate([ 'children' => [ ['profile' => ['name' => 'one']], - ['name' => 'two'], + ['profile' => ['name' => 'two']], ], ]); $this->fail('Expected nested class rules to fail.'); } catch (ValidationException $exception) { - $this->assertArrayHasKey('children.0.profile.name', $exception->errors()); - $this->assertArrayHasKey('children.1.name', $exception->errors()); + $this->assertSame( + ['children.0.profile.name', 'children.1.profile.name'], + array_keys($exception->errors()), + ); } } @@ -234,14 +678,14 @@ public function testUniformStaticCollectionUsesWildcardRules(): void { $rules = ValidatedParentDataFixture::getValidationRules([ 'children' => [ - ['name' => 'Taylor'], - ['name' => 'Swift'], + ['profile' => ['name' => 'Taylor']], + ['profile' => ['name' => 'Swift']], ], ]); - $this->assertArrayHasKey('children.*.name', $rules); - $this->assertArrayNotHasKey('children.0.name', $rules); - $this->assertArrayNotHasKey('children.1.name', $rules); + $this->assertArrayHasKey('children.*.profile.name', $rules); + $this->assertArrayNotHasKey('children.0.profile.name', $rules); + $this->assertArrayNotHasKey('children.1.profile.name', $rules); } /** @@ -614,6 +1058,21 @@ public function testHonorsIncludedUnvalidatedArrayKeys(): void ); } + /** + * Test ignored computed input retained with unvalidated array keys is never cast. + */ + #[WithConfig('data.features.ignore_exception_when_trying_to_set_computed_property_value', true)] + public function testIgnoredComputedInputRetainedWithUnvalidatedArrayKeysIsNotCast(): void + { + $this->app->make(ValidationFactory::class)->includeUnvalidatedArrayKeys(); + + $data = ComputedParentDataFixture::validateAndCreate([ + 'child' => ['first' => 'Taylor', 'full' => ['not', 'a', 'string']], + ]); + + $this->assertSame('Taylor!', $data->child->full); + } + /** * Test uniform morph collections retain wildcard paths for equal dynamic rules. */ @@ -855,6 +1314,17 @@ public function testCompilesValidationAttributes(): void $this->assertSame(['required', 'string'], $rules['name']); } + /** + * Test a declared requiring rule drops only the sometimes rule inferred for an Optional property. + */ + public function testDeclaredSometimesSurvivesARequiringRule(): void + { + $rules = SometimesRequiredWithDataFixture::getValidationRules([]); + + $this->assertSame(['string', 'required_with:other'], $rules['inferred']); + $this->assertSame(['string', 'sometimes', 'required_with:other'], $rules['declared']); + } + /** * Test class-owned rules replace generated rules by default. */ @@ -954,16 +1424,102 @@ public function testValidationOnlyModeBypassesNamedFactories(): void } /** - * Test rule introspection retains its upstream array-only payload contract. + * Test nested input Fill cannot read fails validation at its own path instead of creation. */ - public function testRuleIntrospectionAcceptsOnlyArrays(): void + public function testUnreadableNestedInputFailsValidationAtItsPath(): void { - $parameter = (new ReflectionMethod( - ValidatedDataFixture::class, - 'getValidationRules', - ))->getParameters()[0]; + $valid = ['child' => ['name' => null], 'children' => []]; + + foreach ([ + ['child', [...$valid, 'child' => 'text']], + ['child', [...$valid, 'child' => '']], + // Blank strings skip non-implicit rules, so the nullable and Optional objects need their own rejection. + ['nullableChild', [...$valid, 'nullableChild' => '']], + ['optionalChild', [...$valid, 'optionalChild' => ' ']], + ['children', [...$valid, 'children' => '']], + // The item class has no required property, so only the item's own shape rules reject it. + ['children.0', [...$valid, 'children' => ['text']]], + ['children.0', [...$valid, 'children' => ['']]], + ['children.0', [...$valid, 'children' => [' ']]], + ] as [$errorKey, $payload]) { + try { + UnreadableParentDataFixture::validate($payload); + $this->fail("Expected [{$errorKey}] to fail validation."); + } catch (ValidationException $exception) { + $this->assertSame([$errorKey], array_keys($exception->errors())); + } + } - $this->assertSame('array', (string) $parameter->getType()); + $this->assertSame( + ['required', 'array'], + UnreadableParentDataFixture::getValidationRules([...$valid, 'children' => ['text']])['children.0'], + ); + $this->assertSame( + ['required', 'nullable', 'array'], + UnreadableParentDataFixture::getValidationRules([...$valid, 'nullableChild' => ''])['nullableChild'], + ); + $this->assertSame( + ['nullable', 'array'], + UnreadableParentDataFixture::getValidationRules([...$valid, 'nullableChild' => null])['nullableChild'], + ); + } + + /** + * Test unreadable root collection items and nested input supplied by a hook fail validation too. + */ + public function testUnreadableCollectionItemsAndHookInputFailValidation(): void + { + foreach (['text', '', ' '] as $item) { + try { + OptionalNameDataFixture::factory()->alwaysValidate()->collect([$item]); + $this->fail("Expected the unreadable item [{$item}] to fail validation."); + } catch (ValidationException $exception) { + $this->assertSame([0], array_keys($exception->errors())); + } + } + + try { + UnreadableParentDataFixture::factory() + ->alwaysValidate() + ->beforeValidation(static fn (array $payload): array => [...$payload, 'child' => 'text']) + ->from(['child' => ['name' => 'Taylor'], 'children' => []]); + $this->fail('Expected the hook-supplied input to fail validation.'); + } catch (ValidationException $exception) { + $this->assertSame(['child'], array_keys($exception->errors())); + } + } + + /** + * Test root input nothing can read still fails creation. + */ + public function testUnreadableRootInputStillFailsCreation(): void + { + $this->expectException(CannotCreateData::class); + + UnreadableParentDataFixture::factory()->alwaysValidate()->from(42); + } + + /** + * Test a morph that valid input leaves unresolved still cannot be created. + */ + public function testUnresolvedMorphThatPassesValidationIsNotCreated(): void + { + $this->expectException(CannotCreateAbstractClass::class); + + NullableMorphBaseDataFixture::validateAndCreate([]); + } + + /** + * Test rule introspection retains its upstream array-only payload contract. + */ + public function testRuleIntrospectionAcceptsOnlyArrays(): void + { + $parameter = (new ReflectionMethod( + ValidatedDataFixture::class, + 'getValidationRules', + ))->getParameters()[0]; + + $this->assertSame('array', (string) $parameter->getType()); } /** @@ -996,6 +1552,144 @@ public function testFinishedNestedDataSkipsDeclaredRulesAndRetainsIdentity(): vo $this->assertSame($child, $parent->child); } + /** + * Test a finished value's own property still applies its declared and class exclusions. + * + * @param class-string $class + */ + #[DataProvider('finishedValueExclusionCases')] + public function testFinishedValuesApplyTheirPropertysExclusions(string $class): void + { + $child = new FinishedValidatedChildDataFixture('x'); + + $this->assertNull($class::validateAndCreate(['skip' => true, 'child' => $child])->child); + $this->assertSame($child, $class::validateAndCreate(['skip' => false, 'child' => $child])->child); + } + + /** + * Get the forms that declare an exclusion for a finished value's property. + * + * @return array}> + */ + public static function finishedValueExclusionCases(): array + { + return [ + 'attribute' => [FinishedExcludedAttributeDataFixture::class], + 'rule attribute' => [FinishedExcludedRuleDataFixture::class], + 'class rule' => [FinishedExcludedClassRuleDataFixture::class], + ]; + } + + /** + * Test a custom rule declared on a finished value's property receives the object. + */ + public function testFinishedValuesApplyTheirPropertysCustomRules(): void + { + $child = new FinishedValidatedChildDataFixture('allowed'); + + $this->assertSame($child, FinishedCustomRuleDataFixture::validateAndCreate(['child' => $child])->child); + + try { + FinishedCustomRuleDataFixture::validateAndCreate(['child' => new FinishedValidatedChildDataFixture('blocked')]); + $this->fail('Expected the custom rule to reject the finished value.'); + } catch (ValidationException $exception) { + $this->assertSame(['child' => ['The child is blocked.']], $exception->errors()); + } + } + + /** + * Test a rule rejecting a finished value or item uses the class's custom message for it. + * + * @param class-string $class + * @param array $payload + * @param array> $errors + */ + #[DataProvider('finishedValueMessageCases')] + public function testFinishedValuesUseTheirCustomMessages(string $class, array $payload, array $errors): void + { + try { + $class::validateAndCreate($payload); + $this->fail('Expected the finished value to be prohibited.'); + } catch (ValidationException $exception) { + $this->assertSame($errors, $exception->errors()); + } + } + + /** + * Get the finished values whose own rule has a custom message. + * + * @return array, array, array>}> + */ + public static function finishedValueMessageCases(): array + { + $finished = new FinishedValidatedChildDataFixture('finished'); + $children = [$finished, ['name' => 'raw']]; + + return [ + 'property' => [ + FinishedProhibitedMessageDataFixture::class, + ['child' => $finished], + ['child' => ['A child cannot be given.']], + ], + 'exact item' => [ + FinishedProhibitedItemMessageDataFixture::class, + ['children' => $children], + ['children.0' => ['The first child cannot be given.']], + ], + 'wildcard items' => [ + FinishedProhibitedItemsMessageDataFixture::class, + ['children' => $children], + ['children.0' => ['No child can be given.'], 'children.1' => ['No child can be given.']], + ], + ]; + } + + /** + * Test a field-only message for a mapped nested field follows the field's input name. + */ + public function testFieldOnlyMessagesFollowMappedNestedNames(): void + { + try { + MappedFieldMessageParentDataFixture::validateAndCreate(['child' => ['age' => 1]]); + $this->fail('Expected the mapped nested field to be required.'); + } catch (ValidationException $exception) { + $this->assertSame(['child.full_name' => ['Supply a name.']], $exception->errors()); + } + } + + /** + * Test finished items apply parent rules targeting the items themselves beside raw siblings. + * + * @param class-string $class + * @param list $remainingKeys + */ + #[DataProvider('finishedItemExclusionCases')] + public function testFinishedItemsApplyRulesTargetingThemselves(string $class, array $remainingKeys): void + { + $finished = new FinishedValidatedChildDataFixture('finished'); + $children = [$finished, ['name' => 'raw']]; + + $excluded = $class::validateAndCreate(['skip' => true, 'children' => $children]); + $retained = $class::validateAndCreate(['skip' => false, 'children' => $children]); + + $this->assertSame($remainingKeys, array_keys($excluded->children)); + $this->assertSame($finished, $retained->children[0]); + $this->assertSame('raw', $retained->children[1]->name); + } + + /** + * Get the parent rules targeting finished items, with the item keys their exclusion leaves. + * + * @return array, list}> + */ + public static function finishedItemExclusionCases(): array + { + return [ + 'exact item' => [FinishedExcludedItemDataFixture::class, [1]], + 'wildcard items' => [FinishedExcludedItemsDataFixture::class, []], + ]; + } + /** * Test finished nested properties latch every enclosing collection. */ @@ -1123,7 +1817,7 @@ public function testDirectFactoryFinishedValueDoesNotHideRawSibling(): void try { DirectFinishedParentDataFixture::validateAndCreate([ 'children' => [ - ['finished' => true, 'name' => 'finished'], + 'finished', ['name' => 'invalid'], ], ]); @@ -1218,6 +1912,55 @@ public function testFailOnUnknownFieldsChecksNestedArraysFromRequestQueryInput() } } + /** + * Test a custom request normalizer's single result is the unknown-field input, not the request body. + */ + public function testFailOnUnknownFieldsChecksTheSourceACustomRequestNormalizerReturns(): void + { + $normalizer = new EnvelopeRequestNormalizer; + + $data = StrictValidatedDataFixture::factory() + ->withNormalizers($normalizer) + ->from(Request::create('/', 'POST', ['data' => ['name' => 'Taylor']])); + + $this->assertSame('Taylor', $data->name); + $this->assertSame(1, $normalizer->calls); + + try { + StrictValidatedDataFixture::factory() + ->withNormalizers($normalizer) + ->from(Request::create('/', 'POST', ['data' => ['name' => 'Taylor', 'role' => 'admin']])); + $this->fail('Expected unknown normalized input to fail unknown-field validation.'); + } catch (ValidationException $exception) { + $this->assertSame(['role'], array_keys($exception->errors())); + } + } + + /** + * Test a strict morph subtype selected from its parent checks the request body but not the query string. + */ + public function testFailOnUnknownFieldsAppliesToTheSelectedMorphSubtype(): void + { + $data = StrictMorphBaseDataFixture::from(Request::create('/?tracking=campaign', 'POST', [ + 'type' => 'strict', + 'name' => 'Taylor', + ])); + + $this->assertInstanceOf(StrictMorphChildDataFixture::class, $data); + $this->assertSame('Taylor', $data->name); + + try { + StrictMorphBaseDataFixture::from(Request::create('/?tracking=campaign', 'POST', [ + 'type' => 'strict', + 'name' => 'Taylor', + 'role' => 'admin', + ])); + $this->fail('Expected the selected subtype to fail unknown-field validation.'); + } catch (ValidationException $exception) { + $this->assertSame(['role'], array_keys($exception->errors())); + } + } + /** * Test a strict parent checks caller input removed by its prepare hook. */ @@ -1372,6 +2115,41 @@ public function testFactoryValidationHooksRunInFlowOrder(): void ], $calls); } + /** + * Test validation hooks receive and may supply undeclared input, including for a node they add. + */ + public function testValidationHooksReceiveAndSupplyUndeclaredInput(): void + { + $received = null; + + try { + ConditionalNameDataFixture::factory() + ->alwaysValidate() + ->beforeValidation(function (array $payload) use (&$received): array { + $received = $payload; + + return [...$payload, 'mode' => 'admin']; + }) + ->from(['locale' => 'en']); + $this->fail('Expected the hook-supplied condition to require the name.'); + } catch (ValidationException $exception) { + $this->assertArrayHasKey('name', $exception->errors()); + } + + $this->assertSame(['locale' => 'en'], $received); + + ContextParentDataFixture::$contexts = []; + ContextParentDataFixture::factory() + ->alwaysValidate() + ->beforeValidation(static fn (array $payload): array => [ + ...$payload, + 'child' => ['kind' => 'primary', 'value' => 'a'], + ]) + ->from([]); + + $this->assertSame(['kind' => 'primary', 'value' => 'a'], ContextParentDataFixture::$contexts['child'][0]); + } + /** * Test validation hooks can add a nested data value before rules are compiled. */ @@ -1446,6 +2224,62 @@ public function testBeforeValidationDoesNotReplaceScalarMappedAncestorsWithArray $this->assertSame(['id' => 1], $payload); } + public function testRestoresMappedUnvalidatedValuesIntoUnvalidatedContainers(): void + { + $this->assertSame( + ['id' => 1, 'nested' => ['value' => 'kept']], + HookMappedUnvalidatedDataFixture::validate(['id' => 1, 'nested' => ['value' => 'kept']]), + ); + } + + public function testUnvalidatedConstructorInputsArePreserved(): void + { + $data = ValidatedConstructorInputData::validateAndCreate([ + 'prefix' => 'p', + 'options' => ['a' => 1, 'nested' => ['b' => 2]], + 'secret' => 's', + ]); + + $this->assertSame( + ['prefix' => 'p', 'options' => ['a' => 1, 'nested' => ['b' => 2]], 'secret' => 's'], + $data->received, + ); + } + + public function testRulesGovernConstructorInputs(): void + { + $data = GovernedConstructorInputData::validateAndCreate([ + 'prefix' => 'p', + 'options' => ['a' => 1, 'b' => 2], + 'secret' => 's', + ]); + + $this->assertSame(['prefix' => 'p', 'options' => ['a' => 1], 'secret' => 'none'], $data->received); + } + + public function testParentRulesGovernNestedConstructorInputsAndExclusions(): void + { + $data = ConstructorInputParentData::validateAndCreate([ + 'items' => [['prefix' => 'a', 'secret' => 'x']], + 'skip' => true, + 'child' => ['prefix' => 'c', 'secret' => 'y', 'note' => 'unvalidated'], + ]); + + $this->assertSame(['prefix' => 'a', 'options' => [], 'secret' => 'none'], $data->items[0]->received); + $this->assertNull($data->child); + } + + public function testValidationHooksEditConstructorInputs(): void + { + $data = ValidatedConstructorInputData::factory() + ->alwaysValidate() + ->beforeValidation(static fn (array $payload): array => [...$payload, 'prefix' => 'before']) + ->afterValidation(static fn (array $payload): array => [...$payload, 'secret' => 'after']) + ->from(['prefix' => 'original', 'secret' => 'original']); + + $this->assertSame(['prefix' => 'before', 'options' => [], 'secret' => 'after'], $data->received); + } + /** * Test validation hooks can replace filled data with a named-factory value. */ @@ -1647,7 +2481,7 @@ public function testClassAndFactoryValidatorHooksRunInOrder(): void } /** - * Test nested messages and labels follow each selected wire path. + * Test nested messages and labels keyed by PHP property names follow each item's mapped wire path. */ public function testTranslatesNestedMessagesAndAttributesToObservedWirePaths(): void { @@ -1663,7 +2497,7 @@ public function testTranslatesNestedMessagesAndAttributesToObservedWirePaths(): LifecycleMessagesParentDataFixture::validateAndCreate([ 'children' => [ ['profile' => ['name' => 123]], - ['name' => 456], + ['profile' => ['name' => 456]], ], ]); $this->fail('Expected nested validation to fail.'); @@ -1674,7 +2508,50 @@ public function testTranslatesNestedMessagesAndAttributesToObservedWirePaths(): ); $this->assertSame( ['Invalid display name.'], - $exception->errors()['children.1.name'], + $exception->errors()['children.1.profile.name'], + ); + } + } + + /** + * Test collection items get the same attribute names whether their rules compile to one wildcard or per item. + */ + public function testCollectionAttributeNamesMatchForUniformAndDivergentItems(): void + { + $messages = []; + + foreach ([ + 'uniform' => [['first_name' => 1], ['first_name' => 2]], + 'divergent' => [['first_name' => 1], ['first_name' => 2, 'strict' => true]], + ] as $case => $items) { + try { + NamedItemsDataFixture::validate(['items' => $items]); + $this->fail("Expected the {$case} items to fail validation."); + } catch (ValidationException $exception) { + $messages[$case] = $exception->errors()['items.0.first_name']; + } + } + + $this->assertSame(['The items.0.first name field must be a string.'], $messages['uniform']); + $this->assertSame($messages['uniform'], $messages['divergent']); + } + + /** + * Test a formatter set in a validator hook replaces the collection attribute formatter. + */ + public function testValidatorHooksCanReplaceTheCollectionAttributeFormatter(): void + { + try { + NamedItemsDataFixture::factory() + ->withValidator(static function (Validator $validator): void { + $validator->setImplicitAttributesFormatter(static fn (string $attribute): string => strtoupper($attribute)); + }) + ->validate(['items' => [['first_name' => 1]]]); + $this->fail('Expected the item to fail validation.'); + } catch (ValidationException $exception) { + $this->assertSame( + ['The ITEMS.0.FIRST_NAME field must be a string.'], + $exception->errors()['items.0.first_name'], ); } } @@ -1847,59 +2724,546 @@ public function testPrecognitionRetainsWildcardIdentityForSelectedDataRules(): v } catch (ValidationException $exception) { $this->assertSame([ 'items.1.item_code' => [ - 'The items.1.item_code field has a duplicate value.', + 'The items.1.item code field has a duplicate value.', ], ], $exception->errors()); } } } -class ValidatedDataFixture extends Data -{ - public function __construct( - public int $id, - public ?string $nickname, - public string|Optional $note, - public string $label = 'default', - ) { - } -} - -class ValidatedDtoFixture extends Dto +class PresentCollectionDataFixture extends Data { + /** + * Create a fixture with each kind of collection presence. + */ public function __construct( - public int $id, + #[DataCollectionOf(SimpleData::class)] + public array $items, + #[DataCollectionOf(SimpleData::class)] + public ?array $nullableItems, + #[DataCollectionOf(SimpleData::class)] + public Optional|array $optionalItems, + #[DataCollectionOf(SimpleData::class), Required] + public array $requiredItems, + public array $plain, ) { } } -class ValidatedResourceFixture extends Resource +#[MergeValidationRules] +class MergedRequiredCollectionDataFixture extends Data { + /** + * Create a fixture whose collection also has a class rule. + */ public function __construct( - public int $id, + #[DataCollectionOf(SimpleData::class)] + public array $items, ) { } -} -class ValidatedChildDataFixture extends Data -{ - public function __construct( - #[MapInputName('profile.name')] - public string $name, - ) { + /** + * Get class-owned validation rules. + */ + public static function rules(): array + { + return ['items' => ['required']]; } } -class ValidatedParentDataFixture extends Data +class ReplacedCollectionRulesDataFixture extends Data { /** - * Create a validated parent fixture. - * - * @param array $children + * Create a fixture whose collection rules are replaced. */ public function __construct( - #[DataCollectionOf(ValidatedChildDataFixture::class)] - public array $children, + #[DataCollectionOf(SimpleData::class)] + public array $items, + ) { + } + + /** + * Get class-owned validation rules. + */ + public static function rules(): array + { + return ['items' => ['array']]; + } +} + +class ValidatedDataFixture extends Data +{ + public function __construct( + public int $id, + public ?string $nickname, + public string|Optional $note, + public string $label = 'default', + ) { + } +} + +class UnionTypeRuleDataFixture extends Data +{ + /** + * Create a fixture whose unions hold scalars, containers, and data objects. + * + * @param Collection|Optional|string $collectionOrString + */ + public function __construct( + public string|SimpleData|Optional $stringOrData, + #[Min(5)] + public int|SimpleData|Optional $intOrData, + public Collection|array|Optional $container, + public array|SimpleData|Optional $arrayOrData, + public Collection|string|Optional $collectionOrString, + ) { + } +} + +class ConfirmedPasswordDataFixture extends Data +{ + /** + * Create a confirmed password fixture. + */ + public function __construct( + #[Confirmed] + public string $password, + ) { + } +} + +class ConditionalNameDataFixture extends Data +{ + /** + * Create a conditional name fixture. + */ + public function __construct( + public ?string $name = null, + ) { + } + + /** + * Get class-owned validation rules. + */ + public static function rules(): array + { + return ['name' => ['nullable', 'required_if:mode,admin']]; + } +} + +class MappedConditionalNameDataFixture extends Data +{ + /** + * Create a dot-mapped conditional name fixture. + */ + public function __construct( + #[MapInputName('profile.name')] + public ?string $name = null, + ) { + } + + /** + * Get class-owned validation rules. + */ + public static function rules(): array + { + return ['name' => ['nullable', 'required_if:profile.mode,strict']]; + } +} + +class MaxStringRuleInferrer implements RuleInferrer +{ + /** + * Limit strings that have no maximum yet. + */ + public function handle(DataProperty $property, PropertyRules $rules, ValidationContext $context): PropertyRules + { + if ($rules->hasType(StringType::class) && ! $rules->hasType(Max::class)) { + $rules->add(new Max(255)); + } + + return $rules; + } +} + +class OptionalNicknameRuleInferrer implements RuleInferrer +{ + /** + * Make the nickname optional. + */ + public function handle(DataProperty $property, PropertyRules $rules, ValidationContext $context): PropertyRules + { + if ($property->name === 'nickname') { + $rules->removeType(Required::class)->add(new Sometimes); + } + + return $rules; + } +} + +class ScopedMaxRuleInferrer implements RuleInferrer +{ + /** + * Create a scoped maximum-length rule inferrer. + */ + public function __construct( + public readonly int $max, + ) { + } + + /** + * Limit the name to this inferrer's maximum. + */ + public function handle(DataProperty $property, PropertyRules $rules, ValidationContext $context): PropertyRules + { + if ($property->name === 'name') { + $rules->add(new Max($this->max)); + } + + return $rules; + } +} + +class RemoveMaxRuleInferrer implements RuleInferrer +{ + /** + * Remove every maximum rule. + */ + public function handle(DataProperty $property, PropertyRules $rules, ValidationContext $context): PropertyRules + { + return $rules->removeType(Max::class); + } +} + +#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_PARAMETER)] +class CountingRuleAttribute extends CustomValidationAttribute +{ + public static int $evaluations = 0; + + /** + * Get the Validator rules. + */ + public function getRules(ValidationPath $path): array + { + ++static::$evaluations; + + return ['alpha']; + } +} + +class UnboundReferenceDataFixture extends Data +{ + /** + * Create an unbound reference fixture. + */ + public function __construct( + #[Max(new ContainerReference('unbound-limit'))] + public string $name, + ) { + } +} + +class CountedReferenceDataFixture extends Data +{ + /** + * Create a counted reference fixture. + */ + public function __construct( + #[Max(new ContainerReference('counted-limit')), CountingRuleAttribute] + public string $name, + ) { + } +} + +class ContextChildDataFixture extends Data +{ + /** + * Create a context-recording child fixture. + */ + public function __construct( + public string $value, + ) { + } + + /** + * Record the class rule context. + */ + public static function rules(ValidationContext $context): array + { + ContextParentDataFixture::$contexts['child'] = [$context->payload, $context->fullPayload]; + + return []; + } +} + +class ContextParentDataFixture extends Data +{ + /** @var array */ + public static array $contexts = []; + + /** + * Create a context-recording parent fixture. + */ + public function __construct( + public ContextChildDataFixture $child, + ) { + } + + /** + * Record the class rule context. + */ + public static function rules(ValidationContext $context): array + { + static::$contexts['parent'] = [$context->payload, $context->fullPayload]; + + return []; + } +} + +class PayloadLengthRuleInferrer implements RuleInferrer +{ + /** + * Limit the name according to the item's long flag. + */ + public function handle(DataProperty $property, PropertyRules $rules, ValidationContext $context): PropertyRules + { + if ($property->name === 'name') { + $rules->add(new Max(($context->payload['long'] ?? false) ? 100 : 5)); + } + + return $rules; + } +} + +class RecordingContextRuleInferrer implements RuleInferrer +{ + /** @var array> */ + public static array $contexts = []; + + /** + * Record the validation context each property is inferred with. + */ + public function handle(DataProperty $property, PropertyRules $rules, ValidationContext $context): PropertyRules + { + static::$contexts[$property->name][] = $context; + + return $rules; + } +} + +class RuleInferrerDataFixture extends Data +{ + /** + * Create a rule inferrer fixture. + * + * @param array $meta + */ + public function __construct( + public string $name, + public string $nickname, + #[Max(20)] + public string $code, + #[ArrayType('id')] + public array $meta, + public int $age, + #[Rule('required|string')] + public string $ruled, + ) { + } +} + +#[MergeValidationRules] +class MergedRuleInferrerDataFixture extends Data +{ + /** + * Create a merged-rules inferrer fixture. + */ + public function __construct( + public string $name, + public ?string $other = null, + ) { + } + + /** + * Get the class-owned validation rules. + */ + public static function rules(): array + { + return ['name' => ['required_with:other']]; + } +} + +class RuleInferrerItemDataFixture extends Data +{ + /** + * Create a rule inferrer item fixture. + */ + public function __construct( + public string $name, + public bool $long = false, + ) { + } +} + +class RuleInferrerParentDataFixture extends Data +{ + /** + * Create a rule inferrer parent fixture. + * + * @param array $items + */ + public function __construct( + #[DataCollectionOf(RuleInferrerItemDataFixture::class)] + public array $items, + ) { + } +} + +class ValidatedDtoFixture extends Dto +{ + public function __construct( + public int $id, + ) { + } +} + +class ValidatedResourceFixture extends Resource +{ + public function __construct( + public int $id, + ) { + } +} + +class ValidatedChildDataFixture extends Data +{ + public function __construct( + #[MapInputName('profile.name')] + public string $name, + ) { + } +} + +class MappedValueDataFixture extends Data +{ + /** + * Create a fixture with a mapped input name. + */ + public function __construct( + #[MapInputName('wire')] + public string $value, + ) { + } +} + +class OptionalMappedValueDataFixture extends Data +{ + /** + * Create a fixture with an Optional mapped input name. + */ + public function __construct( + #[MapInputName('wire')] + public string|Optional $value, + ) { + } +} + +#[FailOnUnknownFields] +class StrictMappedValueDataFixture extends Data +{ + /** + * Create a strict fixture with a mapped input name. + */ + public function __construct( + #[MapInputName('wire')] + public string $value, + ) { + } +} + +enum ValidationStatusEnumFixture: string +{ + case Active = 'active'; + case Inactive = 'inactive'; +} + +enum ValidationPriorityEnumFixture: int +{ + case High = 1; + case Low = 2; +} + +class EnumValidatedDataFixture extends Data +{ + /** + * Create a fixture with string- and integer-backed enum properties. + */ + public function __construct( + public ValidationStatusEnumFixture $status, + public ValidationPriorityEnumFixture $priority, + ) { + } +} + +class RestrictedEnumDataFixture extends Data +{ + /** + * Create a fixture whose declared enum rule allows one case. + */ + public function __construct( + #[EnumAttribute(ValidationStatusEnumFixture::class, only: [ValidationStatusEnumFixture::Active])] + public ValidationStatusEnumFixture $status, + ) { + } +} + +class EnumItemDataFixture extends Data +{ + /** + * Create an enum collection item fixture. + */ + public function __construct( + public ValidationStatusEnumFixture $status, + ) { + } + + /** + * Restrict the status to the case named in the item's input. + */ + public static function rules(ValidationContext $context): array + { + $only = $context->payload['only'] ?? null; + + return $only === null + ? [] + : ['status' => [(new EnumRule(ValidationStatusEnumFixture::class))->only(ValidationStatusEnumFixture::from($only))]]; + } +} + +class EnumItemsParentDataFixture extends Data +{ + /** + * Create a fixture holding enum items. + * + * @param array $items + */ + public function __construct( + #[DataCollectionOf(EnumItemDataFixture::class)] + public array $items, + ) { + } +} + +class ValidatedParentDataFixture extends Data +{ + /** + * Create a validated parent fixture. + * + * @param array $children + */ + public function __construct( + #[DataCollectionOf(ValidatedChildDataFixture::class)] + public array $children, ) { } @@ -2329,6 +3693,71 @@ public function __construct( } } +class SometimesRequiredWithDataFixture extends Data +{ + #[RequiredWith('other')] + public string|Optional $inferred; + + #[Sometimes, RequiredWith('other')] + public string|Optional $declared; + + public ?string $other; +} + +class OptionalNameDataFixture extends Data +{ + public ?string $name; +} + +class UnreadableParentDataFixture extends Data +{ + public OptionalNameDataFixture $child; + + public ?OptionalNameDataFixture $nullableChild; + + public OptionalNameDataFixture|Optional $optionalChild; + + /** @var array */ + public array $children; +} + +abstract class NullableMorphBaseDataFixture extends Data implements PropertyMorphableData +{ + #[PropertyForMorph] + public ?string $type; + + /** + * Resolve the concrete class only for the concrete type. + */ + public static function morph(array $properties): ?string + { + return $properties['type'] === 'concrete' ? NullableMorphConcreteDataFixture::class : null; + } +} + +class NullableMorphConcreteDataFixture extends NullableMorphBaseDataFixture +{ +} + +class NamedItemDataFixture extends Data +{ + public string $first_name; + + /** + * Require a longer name for a strict item. + */ + public static function rules(ValidationContext $context): array + { + return ($context->payload['strict'] ?? false) ? ['first_name' => ['string', 'min:3']] : []; + } +} + +class NamedItemsDataFixture extends Data +{ + /** @var array */ + public array $items; +} + class UnvalidatedArrayKeysDataFixture extends Data { public function __construct( @@ -2337,17 +3766,224 @@ public function __construct( } /** - * Get the validated child key inside the array. + * Get the validated child key inside the array. + */ + public static function rules(): array + { + return ['meta.known' => ['nullable', 'string']]; + } +} + +class ComputedChildDataFixture extends Data +{ + #[Computed] + public string $full; + + /** + * Create a computed child fixture. + */ + public function __construct( + public string $first, + ) { + $this->full = $first . '!'; + } +} + +class ComputedParentDataFixture extends Data +{ + /** + * Create a computed parent fixture. + */ + public function __construct( + public ComputedChildDataFixture $child, + ) { + } +} + +class ClassRulesValidatedDataFixture extends Data +{ + public function __construct( + public string $name, + ) { + } + + /** + * Get class-owned validation rules. + */ + public static function rules(ValidationContext $context): array + { + return ['name' => ['min:3']]; + } +} + +#[MergeValidationRules] +class MergedRequiringRulesDataFixture extends Data +{ + public function __construct( + public string $value, + public bool $enabled, + ) { + } + + /** + * Get class-owned validation rules. + */ + public static function rules(): array + { + return [ + 'value' => ['required_if:enabled,true', 'max:10'], + ]; + } +} + +#[MergeValidationRules] +class MergedPresentRuleDataFixture extends Data +{ + public function __construct( + public string $value, + ) { + } + + /** + * Get class-owned validation rules. + */ + public static function rules(): array + { + return ['value' => ['present']]; + } +} + +#[MergeValidationRules] +class ExplicitAndMergedRequiringRulesDataFixture extends Data +{ + public function __construct( + #[Required] + public string $value, + public bool $enabled, + ) { + } + + /** + * Get class-owned validation rules. + */ + public static function rules(): array + { + return ['value' => ['required_if:enabled,true']]; + } +} + +class UnauthorizedValidatedDataFixture extends Data +{ + public function __construct( + public int $id, + ) { + } + + /** + * Determine whether the current Request may create the data object. + */ + public static function authorize(): bool + { + return false; + } +} + +class DeniedDirectFactoryDataFixture extends Data +{ + public static int $factoryCalls = 0; + + public function __construct( + public int $id, + ) { + } + + /** + * Determine whether the current Request may create the data object. + */ + public static function authorize(): AuthorizationResponse + { + return AuthorizationResponse::denyWithStatus( + 403, + 'Denied by policy.', + 'policy-code', + ); + } + + /** + * Create a finished object from a Request. + */ + public static function fromRequest(Request $request): static + { + ++self::$factoryCalls; + + return new static((int) $request->input('id')); + } +} + +class DirectFactoryValidatedDataFixture extends Data +{ + public function __construct( + public int $id, + ) { + } + + /** + * Create a finished object through a named factory. + */ + public static function fromArray(array $payload): static + { + return new static(99); + } +} + +class RuleIntrospectionLifecycleDataFixture extends Data +{ + public static int $rulesCalls = 0; + + public static int $messagesCalls = 0; + + public static int $attributesCalls = 0; + + public function __construct( + public string $value, + ) { + } + + /** + * Get class-owned validation rules. + */ + public static function rules(): array + { + ++self::$rulesCalls; + + return ['value' => ['required']]; + } + + /** + * Get custom validation messages. + */ + public static function messages(): array + { + ++self::$messagesCalls; + + return []; + } + + /** + * Get custom validation attribute labels. */ - public static function rules(): array + public static function attributes(): array { - return ['meta.known' => ['nullable', 'string']]; + ++self::$attributesCalls; + + return []; } } -class ClassRulesValidatedDataFixture extends Data +class FinishedValidatedChildDataFixture extends Data { public function __construct( + #[Required, StringType] public string $name, ) { } @@ -2361,175 +3997,211 @@ public static function rules(ValidationContext $context): array } } -#[MergeValidationRules] -class MergedRequiringRulesDataFixture extends Data +#[FailOnUnknownFields] +class FinishedValidatedParentDataFixture extends Data { public function __construct( - public string $value, - public bool $enabled, + public FinishedValidatedChildDataFixture $child, ) { } /** * Get class-owned validation rules. */ - public static function rules(): array + public static function rules(ValidationContext $context): array { - return [ - 'value' => ['required_if:enabled,true', 'max:10'], - ]; + return ['child.name' => ['required']]; } } -#[MergeValidationRules] -class MergedPresentRuleDataFixture extends Data +class FinishedExcludedAttributeDataFixture extends Data { + /** + * Create a fixture excluding its child through a validation attribute. + */ public function __construct( - public string $value, + public bool $skip, + #[ExcludeIf('skip', true)] + public ?FinishedValidatedChildDataFixture $child = null, ) { } +} +class FinishedExcludedRuleDataFixture extends Data +{ /** - * Get class-owned validation rules. + * Create a fixture excluding its child through a rule attribute. */ - public static function rules(): array - { - return ['value' => ['present']]; + public function __construct( + public bool $skip, + #[Rule('exclude_if:skip,true')] + public ?FinishedValidatedChildDataFixture $child = null, + ) { } } -#[MergeValidationRules] -class ExplicitAndMergedRequiringRulesDataFixture extends Data +class FinishedExcludedClassRuleDataFixture extends Data { + /** + * Create a fixture excluding its child through a class rule. + */ public function __construct( - #[Required] - public string $value, - public bool $enabled, + public bool $skip, + public ?FinishedValidatedChildDataFixture $child = null, ) { } /** * Get class-owned validation rules. */ - public static function rules(): array + public static function rules(ValidationContext $context): array { - return ['value' => ['required_if:enabled,true']]; + return ['child' => ['exclude_if:skip,true']]; } } -class UnauthorizedValidatedDataFixture extends Data +class FinishedCustomRuleDataFixture extends Data { - public function __construct( - public int $id, - ) { - } - /** - * Determine whether the current Request may create the data object. + * Create a fixture checking its child with a custom rule. */ - public static function authorize(): bool - { - return false; + public function __construct( + #[Rule(new RejectsBlockedChildRule)] + public FinishedValidatedChildDataFixture $child, + ) { } } -class DeniedDirectFactoryDataFixture extends Data +class FinishedProhibitedMessageDataFixture extends Data { - public static int $factoryCalls = 0; - + /** + * Create a fixture prohibiting its child with a custom message. + */ public function __construct( - public int $id, + public ?FinishedValidatedChildDataFixture $child = null, ) { } /** - * Determine whether the current Request may create the data object. + * Get class-owned validation rules. */ - public static function authorize(): AuthorizationResponse + public static function rules(ValidationContext $context): array { - return AuthorizationResponse::denyWithStatus( - 403, - 'Denied by policy.', - 'policy-code', - ); + return ['child' => ['prohibited']]; } /** - * Create a finished object from a Request. + * Get class-owned validation messages. */ - public static function fromRequest(Request $request): static + public static function messages(): array { - ++self::$factoryCalls; - - return new static((int) $request->input('id')); + return ['child.prohibited' => 'A child cannot be given.']; } } -class DirectFactoryValidatedDataFixture extends Data +class FinishedProhibitedItemMessageDataFixture extends Data { + /** + * Create a fixture prohibiting its first item with a custom message. + * + * @param array $children + */ public function __construct( - public int $id, + #[DataCollectionOf(FinishedValidatedChildDataFixture::class)] + public array $children, ) { } /** - * Create a finished object through a named factory. + * Get class-owned validation rules. */ - public static function fromArray(array $payload): static + public static function rules(ValidationContext $context): array { - return new static(99); + return ['children.0' => ['prohibited']]; + } + + /** + * Get class-owned validation messages. + */ + public static function messages(): array + { + return ['children.0.prohibited' => 'The first child cannot be given.']; } } -class RuleIntrospectionLifecycleDataFixture extends Data +class FinishedProhibitedItemsMessageDataFixture extends Data { - public static int $rulesCalls = 0; - - public static int $messagesCalls = 0; - - public static int $attributesCalls = 0; - + /** + * Create a fixture prohibiting every item with a custom message. + * + * @param array $children + */ public function __construct( - public string $value, + #[DataCollectionOf(FinishedValidatedChildDataFixture::class)] + public array $children, ) { } /** * Get class-owned validation rules. */ - public static function rules(): array + public static function rules(ValidationContext $context): array { - ++self::$rulesCalls; - - return ['value' => ['required']]; + return ['children.*' => ['prohibited']]; } /** - * Get custom validation messages. + * Get class-owned validation messages. */ public static function messages(): array { - ++self::$messagesCalls; + return ['children.*.prohibited' => 'No child can be given.']; + } +} - return []; +class MappedFieldMessageChildDataFixture extends Data +{ + /** + * Create a child fixture whose name has a different input name. + */ + public function __construct( + #[MapInputName('full_name')] + public string $name, + public int $age, + ) { } +} +class MappedFieldMessageParentDataFixture extends Data +{ /** - * Get custom validation attribute labels. + * Create a parent fixture with a mapped nested field. */ - public static function attributes(): array - { - ++self::$attributesCalls; + public function __construct( + public MappedFieldMessageChildDataFixture $child, + ) { + } - return []; + /** + * Get class-owned validation messages. + */ + public static function messages(): array + { + return ['child.name' => 'Supply a name.']; } } -class FinishedValidatedChildDataFixture extends Data +class FinishedExcludedItemDataFixture extends Data { + /** + * Create a fixture excluding one finished item. + * + * @param array $children + */ public function __construct( - #[Required, StringType] - public string $name, + public bool $skip, + #[DataCollectionOf(FinishedValidatedChildDataFixture::class)] + public array $children, ) { } @@ -2538,15 +4210,21 @@ public function __construct( */ public static function rules(ValidationContext $context): array { - return ['name' => ['min:3']]; + return ['children.0' => ['exclude_if:skip,true']]; } } -#[FailOnUnknownFields] -class FinishedValidatedParentDataFixture extends Data +class FinishedExcludedItemsDataFixture extends Data { + /** + * Create a fixture excluding every item. + * + * @param array $children + */ public function __construct( - public FinishedValidatedChildDataFixture $child, + public bool $skip, + #[DataCollectionOf(FinishedValidatedChildDataFixture::class)] + public array $children, ) { } @@ -2555,7 +4233,20 @@ public function __construct( */ public static function rules(ValidationContext $context): array { - return ['child.name' => ['required']]; + return ['children.*' => ['exclude_if:skip,true']]; + } +} + +class RejectsBlockedChildRule implements ValidationRuleContract +{ + /** + * Reject a child named "blocked". + */ + public function validate(string $attribute, mixed $value, Closure $fail): void + { + if ($value instanceof FinishedValidatedChildDataFixture && $value->name === 'blocked') { + $fail('The child is blocked.'); + } } } @@ -2668,13 +4359,11 @@ public function __construct( } /** - * Finish selected payloads before validation. + * Finish string payloads before validation. */ - public static function fromPayload(array $payload): static|array + public static function fromName(string $name): static { - return ($payload['finished'] ?? false) === true - ? new static($payload['name']) - : $payload; + return new static($name); } /** @@ -2717,6 +4406,40 @@ public function __construct( } } +abstract class StrictMorphBaseDataFixture extends Data implements PropertyMorphableData +{ + /** + * Create the morph base fixture. + */ + public function __construct( + #[PropertyForMorph] + public string $type, + ) { + } + + /** + * Resolve the strict subtype. + */ + public static function morph(array $properties): ?string + { + return $properties['type'] === 'strict' ? StrictMorphChildDataFixture::class : null; + } +} + +#[FailOnUnknownFields] +class StrictMorphChildDataFixture extends StrictMorphBaseDataFixture +{ + /** + * Create the strict subtype fixture. + */ + public function __construct( + string $type, + public string $name, + ) { + parent::__construct($type); + } +} + #[FailOnUnknownFields] class StrictNestedParentDataFixture extends Data { @@ -2842,6 +4565,68 @@ public function __construct( } } +#[FailOnUnknownFields] +class ValidatedConstructorInputData extends Data +{ + #[Computed] + public array $received; + + #[WithoutValidation] + public ?string $note = null; + + /** + * Create a fixture whose constructor parameters have no public data properties. + * + * @param array $options + */ + public function __construct(string $prefix, array $options = [], string $secret = 'none') + { + $this->received = ['prefix' => $prefix, 'options' => $options, 'secret' => $secret]; + } +} + +class GovernedConstructorInputData extends ValidatedConstructorInputData +{ + /** + * Get rules that govern two of the constructor inputs. + */ + public static function rules(): array + { + return [ + 'options.a' => ['integer'], + 'options.b' => ['exclude'], + 'secret' => ['exclude'], + ]; + } +} + +class ConstructorInputParentData extends Data +{ + /** + * Create a parent whose rules reach nested constructor inputs. + * + * @param array $items + */ + public function __construct( + #[DataCollectionOf(ValidatedConstructorInputData::class)] + public array $items, + public bool $skip = false, + public ?ValidatedConstructorInputData $child = null, + ) { + } + + /** + * Get rules for nested constructor inputs and the excluded child. + */ + public static function rules(): array + { + return [ + 'items.*.secret' => ['exclude'], + 'child' => ['exclude_if:skip,true'], + ]; + } +} + class HookMappedUnvalidatedDataFixture extends Data { public function __construct( @@ -2901,6 +4686,25 @@ public function normalize(mixed $value): null } } +class EnvelopeRequestNormalizer implements Normalizer +{ + public int $calls = 0; + + /** + * Read a request's input from its data envelope. + */ + public function normalize(mixed $value): ?array + { + if (! $value instanceof Request) { + return null; + } + + ++$this->calls; + + return $value->input('data'); + } +} + abstract class HookMorphDataFixture extends Data implements PropertyMorphableData { public function __construct( diff --git a/tests/Data/Support/Validation/FieldReferenceMappingTest.php b/tests/Data/Support/Validation/FieldReferenceMappingTest.php new file mode 100644 index 0000000000..1ff31d662e --- /dev/null +++ b/tests/Data/Support/Validation/FieldReferenceMappingTest.php @@ -0,0 +1,428 @@ + 'Taylor'])) + : $class::validate(['last_name' => 'Taylor']); + $this->fail('Expected the mapped field to require the title.'); + } catch (ValidationException $exception) { + $this->assertArrayHasKey('title', $exception->errors()); + } + } + } + + /** + * Provide the supported forms of mapped attribute references. + */ + public static function mappedClasses(): iterable + { + yield 'property mapper' => [PropertyMappedData::class]; + yield 'class mapper' => [ClassMappedData::class]; + yield 'Rule string attribute' => [RuleMappedData::class]; + yield 'attribute returned by rules' => [ClassRulesMappedData::class]; + yield 'preserved input' => [PreservedMappedData::class]; + yield 'explicit input name' => [InputNamedData::class]; + } + + #[WithConfig('data.name_mapping_strategy', ['input' => SnakeCaseMapper::class, 'output' => null])] + public function testGlobalMappingAndDisabledMappingUseTheEffectiveInputName(): void + { + $this->assertContains('required_with:last_name', GlobalMappedData::getValidationRules([])['title']); + $this->assertContains( + 'required_with:lastName', + GlobalMappedData::factory()->withoutPropertyNameMapping()->getValidationRules([])['title'], + ); + } + + public function testDottedMappingAndUndeclaredReferencesRetainTheirInputPaths(): void + { + $rules = DottedMappedData::getValidationRules([]); + $this->assertContains('required_with:profile.name', $rules['title']); + $this->assertContains('required_with:confirmation', $rules['other']); + + try { + DottedMappedData::validate(['profile' => ['name' => 'Taylor']]); + $this->fail('Expected the dotted input to require the title.'); + } catch (ValidationException $exception) { + $this->assertArrayHasKey('title', $exception->errors()); + } + } + + public function testClassRuleStringsRemainLiteral(): void + { + $rules = LiteralRulesData::getValidationRules([]); + $this->assertSame(['required_with:lastName'], $rules['title']); + } + + #[DataProvider('referenceRules')] + public function testRuleHooksResolveFieldParametersWithoutChangingOtherParameters(ValidationRule $rule, string $expected): void + { + foreach (['beforeRules', 'afterRules'] as $stage) { + $factory = PropertyMappedData::factory(); + + if ($stage === 'beforeRules') { + $factory->beforeRules(static fn (DataProperty $property): ?array => $property->name === 'title' ? [$rule] : null); + } else { + $factory->afterRules(static fn (array $rules, DataProperty $property): array => $property->name === 'title' ? [$rule] : $rules); + } + + $this->assertContains($expected, $factory->getValidationRules([])['title']); + } + } + + /** + * Provide conditional, comparison, exclusion and date field references. + */ + public static function referenceRules(): iterable + { + yield 'required if' => [new RequiredIf('lastName', 'lastName'), 'required_if:last_name,lastName']; + yield 'required with' => [new RequiredWith('lastName'), 'required_with:last_name']; + yield 'required with all' => [new RequiredWithAll('lastName', 'confirmation'), 'required_with_all:last_name,confirmation']; + yield 'required without' => [new RequiredWithout('lastName'), 'required_without:last_name']; + yield 'required without all' => [new RequiredWithoutAll('lastName'), 'required_without_all:last_name']; + yield 'same' => [new Same('lastName'), 'same:last_name']; + yield 'different' => [new Different('lastName'), 'different:last_name']; + yield 'greater than' => [new GreaterThan('lastName'), 'gt:last_name']; + yield 'in array' => [new InArray('lastName.*'), 'in_array:last_name.*']; + yield 'exclude if' => [new ExcludeIf('lastName', 'x'), 'exclude_if:last_name,x']; + yield 'prohibited if' => [new ProhibitedIf('lastName', 'x'), 'prohibited_if:last_name,x']; + yield 'prohibits' => [new Prohibits('lastName', 'confirmation'), 'prohibits:last_name,confirmation']; + yield 'present with' => [new PresentWith('lastName'), 'present_with:last_name']; + yield 'missing if' => [new MissingIf('lastName', 'x'), 'missing_if:last_name,x']; + yield 'date reference' => [new After(new FieldReference('lastName')), 'after:last_name']; + } + + #[DataProvider('nestedPayloads')] + public function testNestedAndRootReferencesWorkForObjectsAndCollectionItems(array $payload, array $errorKeys): void + { + try { + NestedRootData::validate($payload); + $this->fail('Expected the mapped conditions to require the nested titles.'); + } catch (ValidationException $exception) { + $this->assertEqualsCanonicalizing($errorKeys, array_keys($exception->errors())); + } + } + + /** + * Provide nested objects, uniform items and items with different input shapes. + */ + public static function nestedPayloads(): iterable + { + yield 'relative object' => [ + ['child' => ['last_name' => 'Taylor']], + ['child.title'], + ]; + yield 'root and nested root paths' => [ + ['require_title' => true, 'meta' => ['require_title' => true], 'child' => []], + ['child.rootTitle', 'child.headerTitle'], + ]; + yield 'uniform collection' => [ + ['items' => [['last_name' => 'Taylor'], ['last_name' => 'River']]], + ['items.0.title', 'items.1.title'], + ]; + yield 'different item shapes' => [ + ['items' => [['last_name' => 'Taylor'], []]], + ['items.0.title'], + ]; + } + + public function testReferenceTraversesMappedCollectionPropertiesAndItemFields(): void + { + $rules = NestedRootData::factory() + ->beforeRules(static fn (DataProperty $property): ?array => $property->name === 'requireTitle' + ? [new RequiredWith('entries.*.lastName')] + : null) + ->getValidationRules([]); + + $this->assertContains('required_with:items.*.last_name', $rules['require_title']); + } + + public function testRootCollectionReferencesRemainLiteral(): void + { + try { + RootCollectionItemData::factory()->alwaysValidate()->collect([ + ['last_name' => 'Taylor'], + ['last_name' => 'River'], + ]); + $this->fail('Expected the literal root path to require both titles.'); + } catch (ValidationException $exception) { + $this->assertEqualsCanonicalizing(['0.title', '1.title'], array_keys($exception->errors())); + } + } + + public function testRootReferencesUseTheSelectedConcreteClass(): void + { + try { + MorphRootData::validate(['type' => 'selected', 'require_title' => true, 'child' => ['last_name' => null]]); + $this->fail('Expected the selected root class mapping to apply.'); + } catch (ValidationException $exception) { + $this->assertSame(['child.rootTitle'], array_keys($exception->errors())); + } + } + + #[WithConfig('data.rule_inferrers', [MappedReferenceInferrer::class])] + public function testCustomInferrersUseMappedFieldReferences(): void + { + try { + InferrerData::validate(['last_name' => 'Taylor']); + $this->fail('Expected the inferrer condition to use the mapped name.'); + } catch (ValidationException $exception) { + $this->assertArrayHasKey('title', $exception->errors()); + } + } + + public function testFinishedValuesStillApplyMappedExclusionConditions(): void + { + $data = FinishedValueData::validateAndCreate([ + 'last_name' => 'hidden', + 'child' => HeaderData::from(['require_title' => false]), + ]); + + $this->assertNull($data->child); + } +} + +class PropertyMappedData extends Data +{ + #[MapInputName('last_name')] + public ?string $lastName; + + #[RequiredWith('lastName')] + public ?string $title; +} + +#[MapInputName(SnakeCaseMapper::class)] +class ClassMappedData extends Data +{ + public ?string $lastName; + + #[RequiredWith('lastName')] + public ?string $title; +} + +class GlobalMappedData extends Data +{ + public ?string $lastName; + + #[RequiredWith('lastName')] + public ?string $title; +} + +class RuleMappedData extends Data +{ + #[MapInputName('last_name')] + public ?string $lastName; + + #[Rule('required_with:lastName')] + public ?string $title; +} + +class ClassRulesMappedData extends Data +{ + #[MapInputName('last_name')] + public ?string $lastName; + + public ?string $title; + + /** + * Require a title when the mapped property is supplied. + */ + public static function rules(): array + { + return ['title' => [new RequiredWith('lastName')]]; + } +} + +class PreservedMappedData extends Data +{ + #[WithoutValidation, MapInputName('last_name')] + public ?string $lastName; + + #[RequiredWith('lastName')] + public ?string $title; +} + +class InputNamedData extends Data +{ + #[MapInputName('last_name')] + public ?string $lastName; + + #[RequiredWith('last_name')] + public ?string $title; +} + +class DottedMappedData extends Data +{ + #[MapInputName('profile.name')] + public ?string $lastName; + + #[RequiredWith('lastName')] + public ?string $title; + + #[RequiredWith('confirmation')] + public ?string $other; +} + +class LiteralRulesData extends ClassRulesMappedData +{ + /** + * Leave ordinary validator rule strings in input-name space. + */ + public static function rules(): array + { + return ['title' => ['required_with:lastName']]; + } +} + +class NestedRootData extends Data +{ + #[MapInputName('require_title')] + public ?bool $requireTitle; + + #[MapInputName('meta')] + public ?HeaderData $header; + + public ?NestedItemData $child; + + /** @var null|array */ + #[MapInputName('items')] + public ?array $entries; +} + +class HeaderData extends Data +{ + #[MapInputName('require_title')] + public bool $requireTitle; +} + +class NestedItemData extends Data +{ + #[MapInputName('last_name')] + public ?string $lastName; + + #[RequiredWith('lastName')] + public ?string $title; + + #[RequiredIf(new FieldReference('requireTitle', fromRoot: true), true)] + public ?string $rootTitle; + + #[RequiredIf(new FieldReference('header.requireTitle', fromRoot: true), true)] + public ?string $headerTitle; +} + +class RootCollectionItemData extends Data +{ + #[MapInputName('last_name')] + public string $lastName; + + #[RequiredWith(new FieldReference('0.last_name', fromRoot: true))] + public ?string $title; +} + +abstract class MorphRootData extends Data implements PropertyMorphableData +{ + #[PropertyForMorph] + public string $type; + + /** + * Select the root class from the discriminator. + */ + public static function morph(array $properties): ?string + { + return $properties['type'] === 'selected' ? SelectedRootData::class : null; + } +} + +class SelectedRootData extends MorphRootData +{ + #[MapInputName('require_title')] + public bool $requireTitle; + + public NestedItemData $child; +} + +class InferrerData extends Data +{ + #[MapInputName('last_name')] + public ?string $lastName; + + public ?string $title; +} + +class MappedReferenceInferrer implements RuleInferrer +{ + /** + * Add the mapped condition to the title's rules. + */ + public function handle(DataProperty $property, PropertyRules $rules, ValidationContext $context): PropertyRules + { + if ($property->name === 'title') { + $rules->add(new RequiredWith('lastName')); + } + + return $rules; + } +} + +class FinishedValueData extends Data +{ + #[MapInputName('last_name')] + public ?string $lastName; + + #[ExcludeIf('lastName', 'hidden')] + public ?HeaderData $child; +} diff --git a/tests/Data/Support/Validation/PropertyRulesTest.php b/tests/Data/Support/Validation/PropertyRulesTest.php new file mode 100644 index 0000000000..58d90233d7 --- /dev/null +++ b/tests/Data/Support/Validation/PropertyRulesTest.php @@ -0,0 +1,71 @@ +add(new Required) + ->add(new Prohibited, new Min(0)); + + $this->assertEquals([new Required, new Prohibited, new Min(0)], $rules->all()); + } + + public function testWillRemoveTheRuleIfANewVersionIsAdded(): void + { + $rules = PropertyRules::create() + ->add(new Min(10)) + ->add(new Min(314)); + + $this->assertEquals([new Min(314)], $rules->all()); + } + + public function testCanRemoveRulesByType(): void + { + $rules = PropertyRules::create() + ->add(new Min(10)) + ->removeType(new Min(314)); + + $this->assertSame([], $rules->all()); + } + + public function testCanRemoveRulesByClass(): void + { + $rules = PropertyRules::create() + ->add(new Min(10)) + ->removeType(Min::class); + + $this->assertSame([], $rules->all()); + } + + public function testPrependsSeveralRulesInOrder(): void + { + $rules = PropertyRules::create(new StringType) + ->prepend(new Required, new Nullable); + + $this->assertEquals([new Required, new Nullable, new StringType], $rules->all()); + } + + public function testAnyRequiringRuleReplacesEveryRequiringRule(): void + { + $rules = PropertyRules::create(new Required, new StringType) + ->add(new RequiredIf('other', 'value')); + + $this->assertEquals([new StringType, new RequiredIf('other', 'value')], $rules->all()); + $this->assertTrue($rules->hasType(RequiredIf::class)); + $this->assertFalse($rules->hasType(Required::class)); + } +} diff --git a/tests/Data/Support/Validation/References/AuthenticatedUserReferenceTest.php b/tests/Data/Support/Validation/References/AuthenticatedUserReferenceTest.php new file mode 100644 index 0000000000..c651f36f0a --- /dev/null +++ b/tests/Data/Support/Validation/References/AuthenticatedUserReferenceTest.php @@ -0,0 +1,88 @@ +make('config')->set('auth.guards.admin', ['driver' => 'session', 'provider' => 'users']); + } + + public function testCanReferenceAScalarPropertyOfTheCurrentUser(): void + { + $this->actingAs($this->user(1, ['max_title_length' => 40])); + + $rules = AuthenticatedUserPropertyData::getValidationRules([]); + + $this->assertSame(['required', 'string', 'max:40'], $rules['title']); + } + + public function testResolvesToNullForAGuest(): void + { + $this->assertNull((new AuthenticatedUserReference)->getValue()); + } + + public function testUsesTheGivenGuard(): void + { + $this->actingAs($this->user(7), 'admin'); + + $this->assertNull((new AuthenticatedUserReference('id', 'web'))->getValue()); + $this->assertSame(7, (new AuthenticatedUserReference('id', 'admin'))->getValue()); + } + + public function testEachValidationResolvesTheCurrentUser(): void + { + $this->actingAs($this->user(1, ['max_title_length' => 10])); + $first = AuthenticatedUserPropertyData::getValidationRules([]); + + $this->actingAs($this->user(2, ['max_title_length' => 20])); + $second = AuthenticatedUserPropertyData::getValidationRules([]); + + $this->assertSame('max:10', $first['title'][2]); + $this->assertSame('max:20', $second['title'][2]); + } + + /** + * Create an unsaved user with the given key and attributes. + * + * @param array $attributes + */ + private function user(int $id, array $attributes = []): User + { + return (new User)->forceFill(['id' => $id, ...$attributes]); + } +} + +class AuthenticatedUserPropertyData extends Data +{ + /** + * Create a fixture limited by an authenticated user property. + */ + public function __construct( + #[Max(new AuthenticatedUserReference('max_title_length'))] + public string $title, + ) { + } +} diff --git a/tests/Data/Support/Validation/References/ContainerReferenceTest.php b/tests/Data/Support/Validation/References/ContainerReferenceTest.php new file mode 100644 index 0000000000..d0cbe221db --- /dev/null +++ b/tests/Data/Support/Validation/References/ContainerReferenceTest.php @@ -0,0 +1,50 @@ +app->bind('upload-limits', static fn (): array => ['size' => 512]); + + $this->assertSame(512, (new ContainerReference('upload-limits', 'size'))->getValue()); + } + + public function testUnresolvableDependencyThrowsInsteadOfResolvingToNull(): void + { + $this->expectException(BindingResolutionException::class); + + ContainerReferenceMaxData::getValidationRules(['property' => 1]); + } +} + +class ContainerReferenceMaxData extends Data +{ + /** + * Create a fixture limited by a container dependency. + */ + public function __construct( + #[Max(value: new ContainerReference('max-allowed-size'))] + public int $property, + ) { + } +} diff --git a/tests/Data/Support/Validation/References/RouteParameterReferenceTest.php b/tests/Data/Support/Validation/References/RouteParameterReferenceTest.php new file mode 100644 index 0000000000..27571d7010 --- /dev/null +++ b/tests/Data/Support/Validation/References/RouteParameterReferenceTest.php @@ -0,0 +1,98 @@ +bindRouteParameters(['post' => $post]); + + $this->assertSame($post, (new RouteParameterReference('post'))->getValue()); + } + + public function testNullableReferenceResolvesAMissingParameterToNull(): void + { + $this->bindRouteParameters([]); + + $this->assertNull((new RouteParameterReference('post', nullable: true))->getValue()); + } + + public function testMissingParameterThrows(): void + { + $this->bindRouteParameters([]); + + $this->expectException(CannotResolveRouteParameterReference::class); + $this->expectExceptionMessage('Cannot find route parameter post with property id'); + + (new RouteParameterReference('post', 'id'))->getValue(); + } + + public function testMissingPropertyOnParameterThrows(): void + { + $this->bindRouteParameters(['post' => RouteParameterPost::make(69)]); + + $this->expectException(CannotResolveRouteParameterReference::class); + $this->expectExceptionMessage('Cannot find property missing in route parameter post'); + + (new RouteParameterReference('post', 'missing'))->getValue(); + } + + public function testEachValidationResolvesTheCurrentRequest(): void + { + $this->bindRouteParameters(['post_id' => '1']); + $first = RouteParameterIgnoreData::getValidationRules([]); + + $this->bindRouteParameters(['post_id' => '2']); + $second = RouteParameterIgnoreData::getValidationRules([]); + + $this->assertSame('unique:posts,NULL,"1",id', (string) $first['property'][2]); + $this->assertSame('unique:posts,NULL,"2",id', (string) $second['property'][2]); + } +} + +class RouteParameterPost extends Model +{ + /** + * Create an unsaved post with the given key. + */ + public static function make(int $id): self + { + return (new self)->forceFill(['id' => $id]); + } +} + +class RouteParameterIgnoreData extends Data +{ + /** + * Create a fixture that ignores a route parameter. + */ + public function __construct( + #[Unique('posts', ignore: new RouteParameterReference('post_id'))] + public int $property, + ) { + } +} diff --git a/tests/Data/Support/Validation/RuleDenormalizerTest.php b/tests/Data/Support/Validation/RuleDenormalizerTest.php new file mode 100644 index 0000000000..bba63d1ff0 --- /dev/null +++ b/tests/Data/Support/Validation/RuleDenormalizerTest.php @@ -0,0 +1,87 @@ +assertEquals($expected, $denormalizer->execute($rule, $path ?? ValidationPath::create(null))); + } + + /** + * Provide rules and their denormalized form. + */ + public static function rules(): iterable + { + yield 'string rule' => ['string', ['string']]; + yield 'multi rule string' => ['string|required', ['string', 'required']]; + yield 'array rule' => [['string|min:3', 'required'], ['string', 'min:3', 'required']]; + yield 'regex rule with a pipe' => ['regex:/test|ok/', ['regex:/test|ok/']]; + yield 'not regex rule with a pipe' => ['not_regex:/test|ok/', ['not_regex:/test|ok/']]; + yield 'notregex rule with a pipe' => ['notregex:/test|ok/', ['notregex:/test|ok/']]; + yield 'capitalized regex rule with a pipe' => ['Regex:/test|ok/', ['Regex:/test|ok/']]; + yield 'rules before a regex' => ['required|regex:/^a/', ['required', 'regex:/^a/']]; + yield 'rules before a not regex' => ['required|not_regex:/^a/', ['required', 'not_regex:/^a/']]; + yield 'string validation attribute rule' => [new Required, ['required']]; + yield 'string validation attribute rule with parameters' => [new Min(3), ['min:3']]; + yield 'string validation attribute rule with parameters to normalize' => [new AcceptedIf('field', DummyBackedEnum::BOO), ['accepted_if:field,boo']]; + yield 'object validation attribute rule' => [new In('a', 'b'), [HypervelRule::in('a', 'b')]]; + yield 'rule attribute rule' => [new RuleAttribute('string|required', new Min(3)), ['string', 'required', 'min:3']]; + yield 'hypervel custom rule' => [new CustomHypervelRule, [new CustomHypervelRule]]; + // REMOVED: Laravel's deprecated InvokableRule contract is not included; ValidationRule replaces it. + yield 'hypervel validation rule' => [new CustomHypervelValidationRule, [new CustomHypervelValidationRule]]; + + // Parameters + yield 'boolean true parameter' => [new AcceptedIf('field', true), ['accepted_if:field,true']]; + yield 'boolean false parameter' => [new AcceptedIf('field', false), ['accepted_if:field,false']]; + yield 'enum parameter' => [new AcceptedIf('field', DummyBackedEnum::BOO), ['accepted_if:field,boo']]; + yield 'empty array parameter' => [new EndsWith, ['ends_with']]; + yield 'array parameter' => [new EndsWith(['test', DummyBackedEnum::BOO]), ['ends_with:test,boo']]; + yield 'date parameter' => [new After(CarbonImmutable::create(2020, 05, 16, 12, timezone: new CarbonTimeZone('Europe/Brussels'))), ['after:2020-05-16T12:00:00+02:00']]; + yield 'field root reference parameter' => [new ExcludeWithout(new FieldReference('field')), ['exclude_without:field']]; + yield 'field nested reference parameter' => [new ExcludeWithout(new FieldReference('field')), ['exclude_without:nested.field'], ValidationPath::create('nested')]; + } + + public function testCanDenormalizeRulesWithRouteParameterReferences(): void + { + $this->bindRouteParameters(['parameter' => '69']); + + $denormalizer = new RuleDenormalizer; + + $this->assertSame( + ['min:69'], + $denormalizer->execute(new Min(new RouteParameterReference('parameter')), ValidationPath::create(null)), + ); + } +} diff --git a/tests/Data/Support/Validation/RuleNormalizerTest.php b/tests/Data/Support/Validation/RuleNormalizerTest.php new file mode 100644 index 0000000000..a5c00fc3dd --- /dev/null +++ b/tests/Data/Support/Validation/RuleNormalizerTest.php @@ -0,0 +1,244 @@ +mapper = $this->app->make(RuleNormalizer::class); + } + + public function testCanMapStringRules(): void + { + $this->assertEquals([new Required], $this->mapper->execute(['required'])); + } + + public function testCanMapStringRulesWithArguments(): void + { + $this->assertEquals([new Exists(rule: new BaseExists('users'))], $this->mapper->execute(['exists:users'])); + } + + public function testCanMapStringRulesWithKeyValueArguments(): void + { + $this->assertEquals( + [new Dimensions(minWidth: 100, minHeight: 200)], + $this->mapper->execute(['dimensions:min_width=100,min_height=200']), + ); + } + + public function testCanMapStringRulesWithRegex(): void + { + $this->assertEquals([new Regex('/test|ok/')], $this->mapper->execute(['regex:/test|ok/'])); + } + + public function testSplitsRulesBeforeARegex(): void + { + $this->assertEquals([new NotRegex('/test|ok/')], $this->mapper->execute(['not_regex:/test|ok/'])); + $this->assertEquals([new Required, new Regex('/^a/')], $this->mapper->execute(['required|regex:/^a/'])); + $this->assertEquals([new Required, new NotRegex('/^a/')], $this->mapper->execute(['required|not_regex:/^a/'])); + } + + public function testKeepsRegexRuleAliasesWhole(): void + { + $this->assertEquals([new Regex('/test|ok/')], $this->mapper->execute(['Regex:/test|ok/'])); + $this->assertEquals([new Rule('notregex:/test|ok/')], $this->mapper->execute(['notregex:/test|ok/'])); + } + + public function testCanMapMultipleRules(): void + { + $this->assertEquals([new Required, new Min(0)], $this->mapper->execute(['required', 'min:0'])); + } + + public function testCanMapMultipleConcatenatedRules(): void + { + $this->assertEquals([new Required, new Min(0)], $this->mapper->execute(['required|min:0'])); + } + + public function testCanMapFaultyRules(): void + { + $this->assertEquals([new Rule('min:')], $this->mapper->execute(['min:'])); + } + + public function testCanMapHypervelRuleObjects(): void + { + $this->assertEquals( + [new Exists(rule: new BaseExists('users'))], + $this->mapper->execute([new BaseExists('users')]), + ); + } + + public function testCanMapACustomHypervelRuleObjects(): void + { + $rule = new class implements CustomRuleContract { + /** + * Determine if the validation rule passes. + */ + public function passes(string $attribute, mixed $value): bool + { + return true; + } + + /** + * Get the validation error message. + */ + public function message(): array|string + { + return ''; + } + }; + + $this->assertEquals([new Rule($rule)], $this->mapper->execute([$rule])); + } + + #[DataProvider('rulesNoAttributeCanHold')] + public function testKeepsRulesNoAttributeCanHoldAsWritten(string $rule): void + { + $this->assertEquals([new Rule($rule)], $this->mapper->execute($rule)); + } + + /** + * Provide rule strings whose parameters, or keyword, no validation attribute can hold. + */ + public static function rulesNoAttributeCanHold(): iterable + { + yield ['alpha:ascii']; + yield ['alpha_dash:ascii']; + yield ['alpha_num:ascii']; + yield ['boolean:strict']; + yield ['integer:strict']; + yield ['numeric:strict']; + yield ['confirmed:repeat_field']; + yield ['image:allow_svg']; + yield ['timezone:per_country,US']; + yield ['uuid:4']; + yield ['accepted_if:field,a,b']; + yield ['exclude_unless:field,a,b']; + yield ['exists:users,email,status,"active"']; + yield ['unique:users,email,5,uid,status,"active"']; + yield ['dimensions:min_ratio=1/2,max_ratio=2/1']; + yield ['can:update,post']; + yield ['unknown_rule:a']; + } + + #[DataProvider('typedRulesWithParameters')] + public function testRebuildsTypedRulesWithTheirParametersAsWritten(string $rule, string $attributeClass): void + { + $normalized = $this->mapper->execute($rule); + + $this->assertCount(1, $normalized); + $this->assertInstanceOf($attributeClass, $normalized[0]); + $this->assertSame([$rule], (new RuleDenormalizer)->execute($normalized, ValidationPath::create())); + } + + /** + * Provide rule strings whose parameters a typed attribute holds as written. + */ + public static function typedRulesWithParameters(): iterable + { + yield ['accepted_if:status,1', AcceptedIf::class]; + yield ['declined_if:status,0', DeclinedIf::class]; + yield ['exclude_if:status,1', ExcludeIf::class]; + yield ['after:2020-01-01', After::class]; + yield ['before_or_equal:2020-01-01 12:00', BeforeOrEqual::class]; + yield ['date_equals:today', DateEquals::class]; + } + + #[DataProvider('validatorRuleObjects')] + public function testKeepsValidatorRuleObjectsAsGiven(object $rule): void + { + $normalized = $this->mapper->execute([$rule]); + + $this->assertCount(1, $normalized); + $this->assertInstanceOf(Rule::class, $normalized[0]); + $this->assertSame([$rule], $normalized[0]->get()); + } + + /** + * Provide rule objects the validator accepts that have no typed attribute. + */ + public static function validatorRuleObjects(): iterable + { + yield 'closure' => [static function (string $attribute, mixed $value, Closure $fail): void {}]; + yield 'fluent rule' => [ValidationRule::date()]; + yield 'nested rules' => [ValidationRule::forEach(static fn (): array => ['integer'])]; + yield 'conditional rules' => [ValidationRule::when(true, ['min:3'])]; + yield 'custom rule' => [new CustomHypervelRule]; + yield 'validation rule' => [new CustomHypervelValidationRule]; + } + + public function testKeepsANativeDatabaseRuleAndItsQueryCallbacks(): void + { + $rule = (new BaseExists('users'))->where(static fn (): null => null); + + $normalized = $this->mapper->execute([$rule]); + + $this->assertCount(1, $normalized); + $this->assertInstanceOf(Exists::class, $normalized[0]); + $this->assertSame($rule, $normalized[0]->getRule(ValidationPath::create())); + } + + public function testNormalizedRulesValidateLikeTheOriginals(): void + { + $rules = [ + 'name' => [ + static function (string $attribute, mixed $value, Closure $fail): void { + if ($value === 'bad') { + $fail('The name is bad.'); + } + }, + ValidationRule::when(static fn (Fluent $data): bool => $data->strict, ['min:5']), + ], + 'items.*' => [ValidationRule::forEach(static fn (): array => ['integer'])], + 'date' => [ValidationRule::date()], + ]; + $denormalizer = new RuleDenormalizer; + $normalized = array_map( + fn (array $fieldRules): array => $denormalizer->execute($this->mapper->execute($fieldRules), ValidationPath::create()), + $rules, + ); + + foreach ([ + [['name' => 'bad', 'strict' => false, 'items' => [1], 'date' => '2020-01-01'], ['name']], + [['name' => 'okay', 'strict' => true, 'items' => [1], 'date' => '2020-01-01'], ['name']], + [['name' => 'okay', 'strict' => false, 'items' => ['x'], 'date' => '2020-01-01'], ['items.0']], + [['name' => 'okay', 'strict' => false, 'items' => [1], 'date' => 'never'], ['date']], + [['name' => 'okay', 'strict' => false, 'items' => [1], 'date' => '2020-01-01'], []], + ] as [$data, $errorKeys]) { + $this->assertSame($errorKeys, $this->app->make('validator')->make($data, $rules)->errors()->keys()); + $this->assertSame($errorKeys, $this->app->make('validator')->make($data, $normalized)->errors()->keys()); + } + } +} diff --git a/tests/Data/Support/Validation/ValidationPathTest.php b/tests/Data/Support/Validation/ValidationPathTest.php index 676c361c24..cc135c5d59 100644 --- a/tests/Data/Support/Validation/ValidationPathTest.php +++ b/tests/Data/Support/Validation/ValidationPathTest.php @@ -9,10 +9,42 @@ class ValidationPathTest extends TestCase { - /** - * Test wildcard segments expand against the complete payload. - */ - public function testExpandsWildcardSegments(): void + public function testExpandsGlobSegmentsInValidationPaths(): void + { + $path = ValidationPath::create('list_items.*.title'); + $matches = $path->matchingWildcardPayloadValidationPaths([ + 'list_items' => [ + ['title' => 'First'], + ['title' => 'Second'], + ], + ]); + + $this->assertSame([ + 'list_items.0.title', + 'list_items.1.title', + ], array_map( + fn (ValidationPath $match): string => (string) $match, + $matches, + )); + } + + public function testExpandsTrailingGlobSegmentsInValidationPaths(): void + { + $path = ValidationPath::create('list_items.*'); + $matches = $path->matchingWildcardPayloadValidationPaths([ + 'list_items' => ['First', 'Second'], + ]); + + $this->assertSame([ + 'list_items.0', + 'list_items.1', + ], array_map( + fn (ValidationPath $match): string => (string) $match, + $matches, + )); + } + + public function testExpandsDeeplyNestedGlobSegmentsInValidationPaths(): void { $payload = [ 'sections' => [ @@ -43,25 +75,6 @@ public function testExpandsWildcardSegments(): void )); } - /** - * Test trailing wildcard segments expand to the matching values. - */ - public function testExpandsTrailingWildcardSegments(): void - { - $path = ValidationPath::create('list_items.*'); - $matches = $path->matchingWildcardPayloadValidationPaths([ - 'list_items' => ['First', 'Second'], - ]); - - $this->assertSame([ - 'list_items.0', - 'list_items.1', - ], array_map( - fn (ValidationPath $match): string => (string) $match, - $matches, - )); - } - /** * Test literal segments after a wildcard include missing validation leaves. */ diff --git a/tests/Data/Support/Validation/ValidationRuleFactoryTest.php b/tests/Data/Support/Validation/ValidationRuleFactoryTest.php new file mode 100644 index 0000000000..209a88ed6f --- /dev/null +++ b/tests/Data/Support/Validation/ValidationRuleFactoryTest.php @@ -0,0 +1,64 @@ +isAbstract() && $reflection->isSubclassOf(ValidationAttribute::class)) { + $expected[$class::keyword()] = $class; + } + } + + $mapping = (new ReflectionClass(ValidationRuleFactory::class))->getConstant('MAPPING'); + ksort($expected); + ksort($mapping); + + $this->assertSame($expected, $mapping); + } + + public function testCreatesTheAttributeForARuleString(): void + { + $this->assertEquals(new Min('3'), (new ValidationRuleFactory)->create('min:3')); + } + + public function testRejectsAnUnknownKeyword(): void + { + $this->expectException(CouldNotCreateValidationRule::class); + $this->expectExceptionMessageIs('Could not create a validation rule for: `unknown:3`'); + + (new ValidationRuleFactory)->create('unknown:3'); + } + + public function testCreatesThroughAnOverriddenMapping(): void + { + $factory = new class extends ValidationRuleFactory { + /** + * Map the min keyword to the max attribute. + */ + protected function mapping(): array + { + return ['min' => Max::class]; + } + }; + + $this->assertEquals(new Max('3'), $factory->create('min:3')); + } +} diff --git a/tests/Data/Support/VarDumper/DataVarDumperCasterTest.php b/tests/Data/Support/VarDumper/DataVarDumperCasterTest.php index 54565a5664..c48016448b 100644 --- a/tests/Data/Support/VarDumper/DataVarDumperCasterTest.php +++ b/tests/Data/Support/VarDumper/DataVarDumperCasterTest.php @@ -19,6 +19,8 @@ use Hypervel\Pagination\CursorPaginator; use Hypervel\Pagination\Paginator; use Hypervel\Testbench\TestCase; +use InvalidArgumentException; +use PHPUnit\Framework\Attributes\DataProvider; use stdClass; use Symfony\Component\VarDumper\Cloner\AbstractCloner; use Symfony\Component\VarDumper\Cloner\Stub; @@ -109,7 +111,7 @@ public function testProviderPreservesAnExistingApplicationCaster(): void try { AbstractCloner::$defaultCasters[TransformableData::class] = $caster; - (new DataServiceProvider($this->app))->boot(); + (new DataServiceProvider($this->app))->boot($this->app->make('config')); $this->assertSame( $caster, @@ -124,6 +126,61 @@ public function testProviderPreservesAnExistingApplicationCaster(): void } } + #[DataProvider('casterModeProvider')] + public function testCasterModeControlsRegistration(string $mode, string $environment, bool $registered): void + { + $hadCaster = array_key_exists( + TransformableData::class, + AbstractCloner::$defaultCasters, + ); + $previousCaster = AbstractCloner::$defaultCasters[TransformableData::class] ?? null; + + try { + unset(AbstractCloner::$defaultCasters[TransformableData::class]); + $this->app->make('config')->set('data.var_dumper_caster_mode', $mode); + $this->app->detectEnvironment(static fn (): string => $environment); + + (new DataServiceProvider($this->app))->boot($this->app->make('config')); + + $this->assertSame( + $registered, + array_key_exists(TransformableData::class, AbstractCloner::$defaultCasters), + ); + } finally { + if ($hadCaster) { + AbstractCloner::$defaultCasters[TransformableData::class] = $previousCaster; + } else { + unset(AbstractCloner::$defaultCasters[TransformableData::class]); + } + } + } + + /** + * Get caster modes with the environment and expected registration. + * + * @return array + */ + public static function casterModeProvider(): array + { + return [ + 'enabled in production' => ['enabled', 'production', true], + 'disabled in testing' => ['disabled', 'testing', false], + 'development locally' => ['development', 'local', true], + 'development in testing' => ['development', 'testing', true], + 'development in production' => ['development', 'production', false], + ]; + } + + public function testInvalidCasterModeIsRejected(): void + { + $this->app->make('config')->set('data.var_dumper_caster_mode', 'sometimes'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Configuration [data.var_dumper_caster_mode]'); + + (new DataServiceProvider($this->app))->boot($this->app->make('config')); + } + public function testOrdinaryObjectsKeepSymfonyDefaultDumping(): void { $object = new stdClass; diff --git a/tests/Data/TransformationTest.php b/tests/Data/TransformationTest.php new file mode 100644 index 0000000000..632ff4af80 --- /dev/null +++ b/tests/Data/TransformationTest.php @@ -0,0 +1,627 @@ +assertSame([ + 'string' => 'Ruben', + ], $data->toArray()); + } + + public function testCanTransformACollectionOfDataObjects(): void + { + $collection = SimpleData::collect(collect([ + 'Ruben', + 'Freek', + 'Brent', + ]), DataCollection::class); + + $this->assertSame([ + ['string' => 'Ruben'], + ['string' => 'Freek'], + ['string' => 'Brent'], + ], $collection->toArray()); + } + + public function testWillUseGlobalTransformersToConvertSpecificTypes(): void + { + $date = new DateTime('16 may 1994'); + + $data = new class($date) extends Data { + /** + * Create a data object with a date. + */ + public function __construct(public DateTime $date) + { + } + }; + + $this->assertSame(['date' => '1994-05-16T00:00:00+00:00'], $data->toArray()); + } + + public function testCanUseAManuallySpecifiedTransformer(): void + { + $date = new DateTime('16 may 1994'); + + $data = new class($date) extends Data { + /** + * Create a data object with a date. + */ + public function __construct( + #[WithTransformer(DateTimeInterfaceTransformer::class, 'd-m-Y')] + public DateTime $date + ) { + } + }; + + $this->assertSame(['date' => '16-05-1994'], $data->toArray()); + } + + public function testATransformerWillNeverHandleANullValue(): void + { + $data = new class(null) extends Data { + /** + * Create a data object with an optional date. + */ + public function __construct( + #[WithTransformer(DateTimeInterfaceTransformer::class, 'd-m-Y')] + public ?DateTime $date + ) { + } + }; + + $this->assertSame(['date' => null], $data->toArray()); + } + + public function testCanGetTheDataObjectWithoutTransforming(): void + { + $lazyData = new SimpleData('Lazy'); + + $data = new class($dataObject = new SimpleData('Test'), $dataCollection = new DataCollection(SimpleData::class, ['A', 'B']), Lazy::create(fn (): SimpleData => $lazyData), 'Test', $transformable = new DateTime('16 may 1994')) extends Data { + /** + * Create a data object with nested, lazy and transformable values. + */ + public function __construct( + public SimpleData $data, + #[DataCollectionOf(SimpleData::class)] + public DataCollection $dataCollection, + public Lazy|Data $lazy, + public string $string, + public DateTime $transformable + ) { + } + }; + + $this->assertSame([ + 'data' => $dataObject, + 'dataCollection' => $dataCollection, + 'string' => 'Test', + 'transformable' => $transformable, + ], $data->all()); + + $this->assertSame([ + 'data' => $dataObject, + 'dataCollection' => $dataCollection, + 'lazy' => $lazyData, + 'string' => 'Test', + 'transformable' => $transformable, + ], $data->include('lazy')->all()); + } + + public function testCanTransformToJson(): void + { + $this->assertSame('{"string":"Hello"}', SimpleData::from('Hello')->toJson()); + $this->assertSame('{"string":"Hello"}', json_encode(SimpleData::from('Hello'))); + } + + public function testCanUseACustomTransformerForADataObjectAndOrDataCollectable(): void + { + $nestedData = new class(42, 'Hello World') extends Data { + /** + * Create a data object with an integer and a string. + */ + public function __construct( + public int $integer, + public string $string, + ) { + } + }; + + $nestedDataCollection = $nestedData::collect([ + ['integer' => 314, 'string' => 'pi'], + ['integer' => '69', 'string' => 'Hypervel after hours'], + ]); + + $dataWithDefaultTransformers = new class($nestedData, $nestedDataCollection) extends Data { + /** + * Create a data object with a nested object and collection. + */ + public function __construct( + public Data $nestedData, + #[DataCollectionOf(SimpleData::class)] + public array $nestedDataCollection, + ) { + } + }; + + $dataWithSpecificTransformers = new class($nestedData, $nestedDataCollection) extends Data { + /** + * Create a data object with custom-transformed nested values. + */ + public function __construct( + #[WithTransformer(ConfidentialDataTransformer::class)] + public Data $nestedData, + #[WithTransformer(ConfidentialDataCollectionTransformer::class), + DataCollectionOf(SimpleData::class)] + public array $nestedDataCollection, + ) { + } + }; + + $this->assertSame([ + 'nestedData' => ['integer' => 42, 'string' => 'Hello World'], + 'nestedDataCollection' => [ + ['integer' => 314, 'string' => 'pi'], + ['integer' => 69, 'string' => 'Hypervel after hours'], + ], + ], $dataWithDefaultTransformers->toArray()); + + $this->assertSame([ + 'nestedData' => ['integer' => 'CONFIDENTIAL', 'string' => 'CONFIDENTIAL'], + 'nestedDataCollection' => [ + ['integer' => 'CONFIDENTIAL', 'string' => 'CONFIDENTIAL'], + ['integer' => 'CONFIDENTIAL', 'string' => 'CONFIDENTIAL'], + ], + ], $dataWithSpecificTransformers->toArray()); + } + + public function testCanTransformBuiltItTypesWithCustomTransformers(): void + { + $data = new class('Hello World', 'Hello World') extends Data { + /** + * Create a data object with plain and transformed strings. + */ + public function __construct( + public string $without_transformer, + #[WithTransformer(StringToUpperTransformer::class)] + public string $with_transformer + ) { + } + }; + + $this->assertSame([ + 'without_transformer' => 'Hello World', + 'with_transformer' => 'HELLO WORLD', + ], $data->toArray()); + } + + public function testWillNotTransformOptionalValues(): void + { + $dataClass = new class('', Optional::create(), Optional::create()) extends Data { + /** + * Create a data object with optional strings. + */ + public function __construct( + public string $string, + public string|Optional $undefinable_string, + #[WithTransformer(StringToUpperTransformer::class)] + public string|Optional $undefinable_string_with_transformer, + ) { + } + }; + + $partialData = $dataClass::from([ + 'string' => 'Hello World', + ]); + + $fullData = $dataClass::from([ + 'string' => 'Hello World', + 'undefinable_string' => 'Hello World', + 'undefinable_string_with_transformer' => 'Hello World', + ]); + + $this->assertSame([ + 'string' => 'Hello World', + ], $partialData->toArray()); + + $this->assertSame([ + 'string' => 'Hello World', + 'undefinable_string' => 'Hello World', + 'undefinable_string_with_transformer' => 'HELLO WORLD', + ], $fullData->toArray()); + } + + public function testWillTransformNativeEnums(): void + { + $data = EnumData::from([ + 'enum' => DummyBackedEnum::FOO, + ]); + + $this->assertSame([ + 'enum' => 'foo', + ], $data->toArray()); + $this->assertSame([ + 'enum' => DummyBackedEnum::FOO, + ], $data->all()); + } + + public function testCanHaveACircularDependencyWhichWillNotGoIntoAnInfiniteLoop(): void + { + $data = CircData::from([ + 'string' => 'Hello World', + 'ular' => [ + 'string' => 'Hello World', + 'circ' => [ + 'string' => 'Hello World', + ], + ], + ]); + + $this->assertEquals( + new CircData('Hello World', new UlarData('Hello World', new CircData('Hello World', null))), + $data, + ); + + $this->assertSame([ + 'string' => 'Hello World', + 'ular' => [ + 'string' => 'Hello World', + 'circ' => [ + 'string' => 'Hello World', + 'ular' => null, + ], + ], + ], $data->toArray()); + } + + public function testCanHaveAHiddenValue(): void + { + $dataObject = new class('', '') extends Data { + /** + * Create a data object with a shown and a hidden value. + */ + public function __construct( + public string $show, + #[Hidden] + public string $hidden, + ) { + } + }; + + $created = $dataObject::from(['show' => 'Yes', 'hidden' => 'No']); + + $this->assertSame('Yes', $created->show); + $this->assertSame('No', $created->hidden); + + $validated = $dataObject::validateAndCreate(['show' => 'Yes', 'hidden' => 'No']); + + $this->assertSame('Yes', $validated->show); + $this->assertSame('No', $validated->hidden); + + $this->assertSame(['show' => 'Yes'], $dataObject::from(['show' => 'Yes', 'hidden' => 'No'])->toArray()); + } + + public function testIsPossibleToAddExtraGlobalTransformersWhenTransformingUsingContext(): void + { + $dataClass = new class extends Data { + public DateTime $dateTime; + }; + + $data = $dataClass::from([ + 'dateTime' => new DateTime, + ]); + + $customTransformer = new class implements Transformer { + /** + * Transform every value to a fixed string. + */ + public function transform(DataProperty $property, mixed $value, TransformationContext $context): string + { + return 'Custom transformed date'; + } + }; + + $transformed = $data->transform( + TransformationContextFactory::create()->withTransformer(DateTimeInterface::class, $customTransformer) + ); + + $this->assertSame([ + 'dateTime' => 'Custom transformed date', + ], $transformed); + } + + public function testCanTransformAPaginatedDataCollection(): void + { + $items = Collection::times(100, fn (int $index): string => "Item {$index}"); + + $paginator = new LengthAwarePaginator( + $items->forPage(1, 15), + 100, + 15 + ); + + $collection = new PaginatedDataCollection(SimpleData::class, $paginator); + + $this->assertInstanceOf(PaginatedDataCollection::class, $collection); + + $output = $collection->toArray(); + + $this->assertSame(['data', 'links', 'meta'], array_keys($output)); + + $this->assertCount(15, $output['data']); + $this->assertSame(['string' => 'Item 1'], $output['data'][0]); + $this->assertSame(['string' => 'Item 15'], $output['data'][14]); + + $this->assertCount(9, $output['links']); + + $this->assertSame([ + 'current_page' => 1, + 'first_page_url' => '/?page=1', + 'from' => 1, + 'last_page' => 7, + 'last_page_url' => '/?page=7', + 'next_page_url' => '/?page=2', + 'path' => '/', + 'per_page' => 15, + 'prev_page_url' => null, + 'to' => 15, + 'total' => 100, + ], $output['meta']); + } + + public function testCanTransformAPaginatedCursorDataCollection(): void + { + $items = Collection::times(100, fn (int $index): string => "Item {$index}"); + + $paginator = new CursorPaginator( + $items, + 15, + ); + + $collection = new CursorPaginatedDataCollection(SimpleData::class, $paginator); + + $this->assertInstanceOf(CursorPaginatedDataCollection::class, $collection); + $this->assertJsonStringEqualsJsonString(json_encode([ + 'data' => array_map(fn (int $index): array => ['string' => "Item {$index}"], range(1, 15)), + 'links' => [], + 'meta' => [ + 'path' => '/', + 'per_page' => 15, + 'next_cursor' => 'eyJfcG9pbnRzVG9OZXh0SXRlbXMiOnRydWV9', + 'next_page_url' => '/?cursor=eyJfcG9pbnRzVG9OZXh0SXRlbXMiOnRydWV9', + 'prev_cursor' => null, + 'prev_page_url' => null, + ], + ]), $collection->toJson()); + } + + // REMOVED: 'can transform a data collection'; DataCollection::through() is deprecated upstream, so use toCollection(). + + public function testCanTransformADataCollectionIntoJson(): void + { + $collection = (new DataCollection(SimpleData::class, ['A', 'B', 'C'])); + + $this->assertSame('[{"string":"A"},{"string":"B"},{"string":"C"}]', $collection->toJson()); + $this->assertSame('[{"string":"A"},{"string":"B"},{"string":"C"}]', json_encode($collection)); + } + + public function testCanTransformATypedIterableWithACustomTransformer(): void + { + $dataClass = new class extends Data { + /** @var array */ + public array $array; + }; + + $transformed = $dataClass::from(['array' => ['a', 'b', 'c']])->transform( + TransformationContextFactory::create() + ->withTransformer('string', StringToUpperTransformer::class) + ); + + $this->assertSame(['array' => ['A', 'B', 'C']], $transformed); + } + + public function testDoesNotTransformATypedIterableWithACustomTransformerWhenAUnionTypeIsUsedWithANonIterableValue(): void + { + $dataClass = new class extends Data { + /** @var array|string */ + public string|array $array; + }; + + $transformed = $dataClass::from(['array' => 'a'])->transform( + TransformationContextFactory::create() + ->withTransformer('string', StringToUpperTransformer::class) + ); + + $this->assertSame(['array' => 'A'], $transformed); + } + + public function testItPossibleToSetTheMaxTransformationDepthWhenTransformingObjects(): void + { + $a = new stdClass; + $b = new stdClass; + + $a->b = $b; + $b->a = $a; + + $this->assertSame([ + 'dataA' => [ + 'dataB' => [ + 'dataA' => [ + 'dataB' => [], + ], + ], + ], + ], TestMaxDataObjectTransformationDepthB::fromOther($a)->transform( + TransformationContextFactory::create()->maxDepth(4, throw: false) + )); + + $this->expectException(MaxTransformationDepthReached::class); + $this->expectExceptionMessage('Max transformation depth of 4 reached.'); + + TestMaxDataObjectTransformationDepthB::fromOther($a)->transform( + TransformationContextFactory::create()->maxDepth(4) + ); + } + + public function testItPossibleToSetTheMaxTransformationDepthWhenTransformingCollections(): void + { + $a = new stdClass; + $b = new stdClass; + + $a->b = $b; + $b->a = $a; + + $this->assertSame([ + 'cb' => [ + [ + 'ca' => [ + [ + 'cb' => [ + ['ca' => []], + ], + ], + ], + ], + ], + ], TestMaxDatCollectionTransformationDepthB::fromOther($a)->transform( + TransformationContextFactory::create()->maxDepth(4, throw: false) + )); + + $this->expectException(MaxTransformationDepthReached::class); + $this->expectExceptionMessage('Max transformation depth of 4 reached.'); + + TestMaxDatCollectionTransformationDepthB::fromOther($a)->transform( + TransformationContextFactory::create()->maxDepth(4) + ); + } +} + +class TestMaxDataObjectTransformationDepthA extends Data +{ + /** + * Create a data object that always includes its lazy counterpart. + */ + public function __construct( + public Lazy|TestMaxDataObjectTransformationDepthB $dataB + ) { + $this->includePermanently('dataB'); + } + + /** + * Create the data object from one side of a circular object graph. + */ + public static function fromOther(stdClass $b): self + { + return new self(Lazy::create(fn (): TestMaxDataObjectTransformationDepthB => TestMaxDataObjectTransformationDepthB::from($b->a))); + } +} + +class TestMaxDataObjectTransformationDepthB extends Data +{ + /** + * Create a data object that always includes its lazy counterpart. + */ + public function __construct( + public Lazy|TestMaxDataObjectTransformationDepthA $dataA + ) { + $this->includePermanently('dataA'); + } + + /** + * Create the data object from one side of a circular object graph. + */ + public static function fromOther(stdClass $a): self + { + return new self(Lazy::create(fn (): TestMaxDataObjectTransformationDepthA => TestMaxDataObjectTransformationDepthA::from($a->b))); + } +} + +class TestMaxDatCollectionTransformationDepthA extends Data +{ + /** + * Create a data object that always includes its lazy collection. + */ + public function __construct( + #[DataCollectionOf(TestMaxDatCollectionTransformationDepthB::class)] + public Lazy|DataCollection $ca + ) { + $this->includePermanently('ca'); + } + + /** + * Create the data object from one side of a circular object graph. + */ + public static function fromOther(stdClass $b): self + { + return new self(Lazy::create(fn (): array => TestMaxDatCollectionTransformationDepthB::collect([$b->a]))); + } +} + +class TestMaxDatCollectionTransformationDepthB extends Data +{ + /** + * Create a data object that always includes its lazy collection. + */ + public function __construct( + #[DataCollectionOf(TestMaxDatCollectionTransformationDepthA::class)] + public Lazy|DataCollection $cb + ) { + $this->includePermanently('cb'); + } + + /** + * Create the data object from one side of a circular object graph. + */ + public static function fromOther(stdClass $a): self + { + return new self(Lazy::create(fn (): array => TestMaxDatCollectionTransformationDepthA::collect([$a->b]))); + } +} diff --git a/tests/Data/Transformers/ArrayableTransformerTest.php b/tests/Data/Transformers/ArrayableTransformerTest.php index dd57e40f75..4db432bfe8 100644 --- a/tests/Data/Transformers/ArrayableTransformerTest.php +++ b/tests/Data/Transformers/ArrayableTransformerTest.php @@ -13,10 +13,7 @@ class ArrayableTransformerTest extends TestCase { - /** - * Test an arrayable value is transformed to an array. - */ - public function testTransformsAnArrayableValue(): void + public function testCanTransformAnArrayable(): void { $value = new Collection(['A', 'B']); diff --git a/tests/Data/Transformers/DateTimeInterfaceTransformerTest.php b/tests/Data/Transformers/DateTimeInterfaceTransformerTest.php index eab91fcd64..85c9569403 100644 --- a/tests/Data/Transformers/DateTimeInterfaceTransformerTest.php +++ b/tests/Data/Transformers/DateTimeInterfaceTransformerTest.php @@ -17,6 +17,7 @@ use Hypervel\Data\Transformers\DateTimeInterfaceTransformer; use Hypervel\Support\Carbon as HypervelCarbon; use Hypervel\Support\CarbonImmutable as HypervelCarbonImmutable; +use Hypervel\Testbench\Attributes\WithConfig; use Hypervel\Testbench\TestCase; use Mockery as m; @@ -27,10 +28,7 @@ protected function getPackageProviders(Application $app): array return [DataServiceProvider::class]; } - /** - * Test supported date implementations use the configured format. - */ - public function testTransformsDates(): void + public function testCanTransformDates(): void { $transformer = new DateTimeInterfaceTransformer; @@ -39,10 +37,7 @@ public function testTransformsDates(): void } } - /** - * Test an explicit format overrides the configured format. - */ - public function testTransformsDatesWithAnAlternativeFormat(): void + public function testCanTransformDatesWithAnAlternativeFormat(): void { $transformer = new DateTimeInterfaceTransformer(format: 'd-m-Y'); @@ -51,25 +46,21 @@ public function testTransformsDatesWithAnAlternativeFormat(): void } } - /** - * Test dates are transformed in an alternative timezone without mutation. - */ - public function testChangesTheTimezoneWithoutMutatingTheValue(): void + public function testCanChangeTheTimezone(): void { $transformer = new DateTimeInterfaceTransformer(setTimeZone: 'Europe/Brussels'); foreach ($this->dates() as $date) { $this->assertSame('1994-05-19T02:00:00+02:00', $this->transform($transformer, $date)); + // The transformed copy changes timezone; the property's value does not. $this->assertSame('UTC', $date->getTimezone()->getName()); } } - /** - * Test a leading reset marker is omitted from output formatting. - */ - public function testTransformsDatesWithLeadingResetMarker(): void + #[WithConfig('data.date_format', '!Y-m-d')] + public function testCanTransformDatesWithLeadingExclamationMark(): void { - $transformer = new DateTimeInterfaceTransformer(format: '!Y-m-d'); + $transformer = new DateTimeInterfaceTransformer; $date = Carbon::createFromFormat('!Y-m-d', '1994-05-19', new DateTimeZone('UTC')); $this->assertSame('1994-05-19', $this->transform($transformer, $date)); diff --git a/tests/Data/Transformers/EnumTransformerTest.php b/tests/Data/Transformers/EnumTransformerTest.php index 836ff247df..929d66d40a 100644 --- a/tests/Data/Transformers/EnumTransformerTest.php +++ b/tests/Data/Transformers/EnumTransformerTest.php @@ -12,10 +12,7 @@ class EnumTransformerTest extends TestCase { - /** - * Test backed enums are transformed to their scalar values. - */ - public function testTransformsBackedEnums(): void + public function testCanTransformEnums(): void { $transformer = new EnumTransformer; $property = m::mock(DataProperty::class); diff --git a/tests/Data/Transformers/SerializeTransformerTest.php b/tests/Data/Transformers/SerializeTransformerTest.php new file mode 100644 index 0000000000..77a96f7eed --- /dev/null +++ b/tests/Data/Transformers/SerializeTransformerTest.php @@ -0,0 +1,25 @@ +assertSame( + serialize(DummyBackedEnum::FOO), + $transformer->transform(m::mock(DataProperty::class), DummyBackedEnum::FOO, new TransformationContext) + ); + } +} diff --git a/tests/Data/ValidationTest.php b/tests/Data/ValidationTest.php new file mode 100644 index 0000000000..9675a5f444 --- /dev/null +++ b/tests/Data/ValidationTest.php @@ -0,0 +1,3765 @@ +assertOk(['property' => 'Hello World']) + ->assertRules([ + 'property' => ['required', 'string'], + ]); + } + + public function testCanValidateAFloat(): void + { + $dataClass = new class extends Data { + public float $property; + }; + + DataValidationAsserter::for($dataClass) + ->assertOk(['property' => 10.0]) + ->assertRules([ + 'property' => ['required', 'numeric'], + ]); + } + + public function testCanValidateAnInteger(): void + { + $dataClass = new class extends Data { + public int $property; + }; + + // Hypervel infers integer rather than Spatie's numeric, so a fractional value fails instead of being truncated. + DataValidationAsserter::for($dataClass) + ->assertOk(['property' => 10.0]) + ->assertErrors(['property' => 10.5]) + ->assertRules([ + 'property' => ['required', 'integer'], + ]); + } + + public function testCanValidateAnArray(): void + { + $dataClass = new class extends Data { + public array $property; + }; + + DataValidationAsserter::for($dataClass) + ->assertOk(['property' => ['Hello World']]) + ->assertRules([ + 'property' => ['required', 'array'], + ]); + } + + public function testCanValidateABool(): void + { + $dataClass = new class extends Data { + public bool $property; + }; + + DataValidationAsserter::for($dataClass) + ->assertOk(['property' => true]) + ->assertOk(['property' => false]) + ->assertErrors([]) + ->assertRules([ + 'property' => ['required', 'boolean'], + ]); + } + + public function testCanValidateANullableType(): void + { + $dataClass = new class extends Data { + public ?array $property; + }; + + DataValidationAsserter::for($dataClass) + ->assertOk(['property' => ['Hello World']]) + ->assertOk(['property' => null]) + ->assertOk([]) + ->assertRules([ + 'property' => ['nullable', 'array'], + ]); + } + + public function testCanValidatedAPropertyWithCustomRules(): void + { + $dataClass = new class extends Data { + public ?array $property; + + /** + * Get the validation rules. + */ + public static function rules(): array + { + return [ + 'property' => ['array', 'min:5'], + ]; + } + }; + + DataValidationAsserter::for($dataClass) + ->assertRules([ + 'property' => ['array', 'min:5'], + ]); + } + + public function testCanValidateAPropertyWithCustomRulesAsString(): void + { + $dataClass = new class extends Data { + public ?array $property; + + /** + * Get the validation rules. + */ + public static function rules(): array + { + return [ + 'property' => 'array|min:5', + ]; + } + }; + + DataValidationAsserter::for($dataClass) + ->assertRules([ + 'property' => ['array', 'min:5'], + ]); + } + + public function testCanValidateAPropertyWithCustomRulesAsObject(): void + { + $dataClass = new class extends Data { + public ?array $property; + + /** + * Get the validation rules. + */ + public static function rules(): array + { + return [ + 'property' => [new ArrayType, new Min(5)], + ]; + } + }; + + DataValidationAsserter::for($dataClass) + ->assertRules([ + 'property' => ['array', 'min:5'], + ]); + } + + public function testCanValidateAPropertyWithAttributes(): void + { + $dataClass = new class extends Data { + #[Min(5)] + public ?array $property; + }; + + DataValidationAsserter::for($dataClass) + ->assertRules([ + 'property' => ['nullable', 'array', 'min:5'], + ]); + } + + public function testCanValidateAnOptionalAttribute(): void + { + DataValidationAsserter::for(new class extends Data { + public array|Optional $property; + }) + ->assertOk([]) + ->assertOk(['property' => []]) + ->assertErrors(['property' => null]) + ->assertRules([ + 'property' => ['sometimes', 'array'], + ]); + + DataValidationAsserter::for(new class extends Data { + public array|Optional|null $property; + }) + ->assertOk([]) + ->assertOk(['property' => []]) + ->assertOk(['property' => null]) + ->assertRules([ + 'property' => ['nullable', 'sometimes', 'array'], + ]); + + DataValidationAsserter::for(new class extends Data { + #[Max(10)] + public array|Optional $property; + }) + ->assertOk([]) + ->assertOk(['property' => []]) + ->assertErrors(['property' => null]) + ->assertRules([ + 'property' => ['sometimes', 'array', 'max:10'], + ]); + } + + public function testCanValidateANativeEnum(): void + { + $dataClass = new class extends Data { + public DummyBackedEnum $property; + }; + + DataValidationAsserter::for($dataClass) + ->assertOk(['property' => 'foo']) + ->assertRules([ + 'property' => ['required', new Enum(DummyBackedEnum::class)], + ]); + } + + public function testWillNeverAddExtraRequireRulesWhenNotRequired(): void + { + DataValidationAsserter::for(new class extends Data { + public ?string $property; + })->assertRules([ + 'property' => [new Nullable, 'string'], + ]); + + DataValidationAsserter::for(new class extends Data { + #[RequiredWith('other')] + public string $property; + })->assertRules([ + 'property' => ['string', 'required_with:other'], + ]); + + DataValidationAsserter::for(new class extends Data { + #[ValidationRule('required_with:other')] + public string $property; + })->assertRules([ + 'property' => ['string', 'required_with:other'], + ]); + } + + public function testIsPossibleToHaveMultipleRequiredRules(): void + { + // Upstream skips this case and expects a malformed required_unless rule; Hypervel adds no + // inferred required rule beside declared presence rules. + DataValidationAsserter::for(new class extends Data { + #[RequiredUnless('is_required', false), RequiredWith('make_required')] + public string $property; + + public string $make_required; + + public bool $is_required; + })->assertRules([ + 'property' => ['string', 'required_unless:is_required,false', 'required_with:make_required'], + 'make_required' => ['required', 'string'], + 'is_required' => ['required', 'boolean'], + ]); + } + + public function testWillTakeCareOfMapping(): void + { + DataValidationAsserter::for(new class extends Data { + #[MapInputName('some_property')] + public string $property; + }) + ->assertOk(['some_property' => 'foo']) + ->assertErrors(['property' => 'foo']) + ->assertRules([ + 'some_property' => ['required', 'string'], + ]); + + DataValidationAsserter::for(new class extends Data { + #[MapName('some_property')] + public string $property; + }) + ->assertOk(['some_property' => 'foo']) + ->assertErrors(['property' => 'foo']) + ->assertRules([ + 'some_property' => ['required', 'string'], + ]); + + DataValidationAsserter::for(new class extends Data { + #[MapName('some_property')] + public SimpleData $property; + }) + ->assertOk(['some_property' => ['string' => 'hi']]) + ->assertErrors(['property' => ['string' => 'hi']]) + ->assertRules([ + 'some_property' => ['required', 'array'], + 'some_property.string' => ['required', 'string'], + ]); + + DataValidationAsserter::for(new class extends Data { + #[DataCollectionOf(SimpleData::class), MapName('some_property')] + public DataCollection $property; + }) + ->assertOk(['some_property' => [['string' => 'hi']]]) + ->assertErrors(['property' => [['string' => 'hi']]]) + ->assertRules([ + 'some_property' => ['present', 'array'], + 'some_property.0.string' => ['required', 'string'], + ], payload: ['some_property' => [[]]]); + + DataValidationAsserter::for(new class extends Data { + #[MapName('some_property')] + public DataWithMapper $property; + }) + ->assertOk([ + 'some_property' => [ + 'cased_property' => 'Hi', + 'data_cased_property' => ['string' => 'Hi'], + 'data_collection_cased_property' => [ + ['string' => 'Hi'], + ], + ], + ]) + ->assertErrors([ + 'property' => [ + 'cased_property' => 'Hi', + 'data_cased_property' => ['string' => 'Hi'], + 'data_collection_cased_property' => [ + ['string' => 'Hi'], + ], + ], + ]) + ->assertErrors([ + 'some_property' => [ + 'casedProperty' => 'Hi', + 'dataCasedProperty' => ['string' => 'Hi'], + 'dataCollectionCasedProperty' => [ + ['string' => 'Hi'], + ], + ], + ]) + ->assertRules([ + 'some_property' => ['required', 'array'], + 'some_property.cased_property' => ['required', 'string'], + 'some_property.data_cased_property' => ['required', 'array'], + 'some_property.data_cased_property.string' => ['required', 'string'], + 'some_property.data_collection_cased_property' => ['present', 'array'], + 'some_property.data_collection_cased_property.0.string' => ['required', 'string'], + ], payload: [ + 'some_property' => [ + 'data_collection_cased_property' => [[]], + ], + ]); + } + + public function testCanDisableValidation(): void + { + $dataClass = new class extends Data { + #[WithoutValidation] + public string $property; + + #[DataCollectionOf(SimpleData::class), WithoutValidation] + public DataCollection $collection; + + #[WithoutValidation] + public SimpleData $data; + }; + + DataValidationAsserter::for($dataClass) + ->assertOk([]) + ->assertRules([]); + } + + public function testCanWriteCustomRulesBasedUponPayloads(): void + { + $dataClass = new class extends Data { + public bool $strict; + + public string $property; + + #[MapInputName(SnakeCaseMapper::class)] + public string $mappedProperty; + + /** + * Get the validation rules for the payload. + */ + public static function rules(ValidationContext $context): array + { + if ($context->payload['strict'] === true) { + return [ + 'property' => ['in:strict'], + 'mapped_property' => ['in:strict'], + ]; + } + + return []; + } + }; + + DataValidationAsserter::for($dataClass) + ->assertRules( + rules: [ + 'strict' => ['required', 'boolean'], + 'property' => ['in:strict'], + 'mapped_property' => ['in:strict'], + ], + payload: [ + 'strict' => true, + ] + ) + ->assertRules( + rules: [ + 'strict' => ['required', 'boolean'], + 'property' => ['required', 'string'], + 'mapped_property' => ['required', 'string'], + ], + payload: [ + 'strict' => false, + ] + ); + } + + public function testCanWriteCustomRulesBasedUponInjectedDependencies(): void + { + $dataClass = new class extends Data { + public string $environment; + + /** + * Get the validation rules for the application environment. + */ + public static function rules(Application $app): array + { + return [ + 'environment' => [new Required, new StringType, In::create($app->environment())], + ]; + } + }; + + DataValidationAsserter::for($dataClass)->assertRules([ + 'environment' => ['required', 'string', 'in:"testing"'], + ]); + } + + public function testCanValidateNestedData(): void + { + $dataClass = new class extends Data { + public SimpleData $nested; + }; + + DataValidationAsserter::for($dataClass) + ->assertOk(['nested' => ['string' => 'Hello World']]) + ->assertErrors(['nested' => []]) + ->assertErrors(['nested' => null]) + ->assertRules([ + 'nested' => ['required', 'array'], + 'nested.string' => ['required', 'string'], + ]); + } + + public function testCanValidateNestedNullableData(): void + { + $dataClass = new class extends Data { + public ?SimpleData $nested; + }; + + DataValidationAsserter::for($dataClass) + ->assertOk(['nested' => ['string' => 'Hello World']]) + ->assertOk(['nested' => null]) + ->assertErrors(['nested' => ['string' => null]]) + ->assertErrors(['nested' => []]) + ->assertRules(['nested' => ['nullable', 'array']], payload: []) + ->assertRules([ + 'nested' => ['nullable', 'array'], + 'nested.string' => ['required', 'string'], + ], payload: ['nested' => []]); + } + + public function testCanValidateNestedOptionalData(): void + { + $dataClass = new class extends Data { + public SimpleData|Optional $nested; + }; + + DataValidationAsserter::for($dataClass) + ->assertOk(['nested' => ['string' => 'Hello World']]) + ->assertErrors(['nested' => null]) + ->assertErrors(['nested' => ['string' => null]]) + ->assertErrors(['nested' => []]) + ->assertRules([ + 'nested' => ['sometimes', 'array'], + ], payload: []) + ->assertRules([ + 'nested' => ['sometimes', 'array'], + 'nested.string' => ['required', 'string'], + ], ['nested' => null]); + } + + public function testCanAddAdditionalRulesToNestedData(): void + { + $dataClass = new class extends Data { + #[Bail] + public SimpleData $nested; + }; + + DataValidationAsserter::for($dataClass) + ->assertRules([ + 'nested' => ['required', 'array', 'bail'], + 'nested.string' => ['required', 'string'], + ]); + } + + public function testWillUseNameMappingWithNestedObjects(): void + { + $dataClass = new class extends Data { + #[MapInputName('some_nested')] + public SimpleData $nested; + }; + + DataValidationAsserter::for($dataClass) + ->assertOk(['some_nested' => ['string' => 'Hello World']]) + ->assertRules([ + 'some_nested' => ['required', 'array'], + 'some_nested.string' => ['required', 'string'], + ]); + } + + public function testCanUseNestedPayloadsInNestedData(): void + { + $dataClass = new class extends Data { + public DummyDataWithContextOverwrittenValidationRules $some_nested; + }; + + DataValidationAsserter::for($dataClass) + ->assertRules( + rules: [ + 'some_nested' => ['required', 'array'], + 'some_nested.validate_as_email' => ['boolean', 'required'], + 'some_nested.string' => ['required', 'string', 'email'], + ], + payload: [ + 'some_nested' => [ + 'validate_as_email' => true, + ], + ] + ) + ->assertRules( + rules: [ + 'some_nested' => ['required', 'array'], + 'some_nested.validate_as_email' => ['boolean', 'required'], + 'some_nested.string' => ['required', 'string'], + ], + payload: [ + 'some_nested' => [ + 'validate_as_email' => false, + ], + ] + ); + } + + public function testCanUseAReferenceToAnotherFieldInData(): void + { + DataValidationAsserter::for(DataWithReferenceFieldValidationAttribute::class) + ->assertOk([ + 'check_string' => '0', + ]) + ->assertErrors([ + 'check_string' => '1', + ]) + ->assertRules( + rules: [ + 'check_string' => ['required', 'boolean'], + // A boolean reference renders as true, which Laravel matches for a boolean field. + 'string' => ['string', 'required_if:check_string,true'], + ], + payload: [ + 'check_string' => '1', + ] + ); + } + + public function testCanUseAReferenceToAnotherFieldInNestedData(): void + { + $dataClass = new class extends Data { + public DataWithReferenceFieldValidationAttribute $nested; + }; + + DataValidationAsserter::for($dataClass) + ->assertOk([ + 'nested' => ['check_string' => '0'], + ]) + ->assertErrors([ + 'nested' => ['check_string' => '1'], + ]) + ->assertRules( + rules: [ + 'nested' => ['required', 'array'], + 'nested.check_string' => ['required', 'boolean'], + 'nested.string' => ['string', 'required_if:nested.check_string,true'], + ], + payload: [ + 'nested' => ['check_string' => '1'], + ] + ); + } + + public function testCanUseAReferenceToAnotherFieldInACollection(): void + { + $dataClass = new class extends Data { + #[DataCollectionOf(DataWithReferenceFieldValidationAttribute::class)] + public DataCollection $collection; + }; + + DataValidationAsserter::for($dataClass) + ->assertOk([ + 'collection' => [ + ['check_string' => '0'], + ], + ]) + ->assertErrors([ + 'collection' => [ + ['check_string' => '1'], + ], + ]) + ->assertRules( + rules: [ + 'collection' => ['present', 'array'], + 'collection.0.check_string' => ['required', 'boolean'], + // A uniform collection references the item's sibling through a wildcard, which Laravel resolves per item. + 'collection.0.string' => ['string', 'required_if:collection.*.check_string,true'], + ], + payload: [ + 'collection' => [['check_string' => '1']], + ] + ); + } + + public function testCanUseAReferenceToAnotherFieldInACollectionWithNestedData(): void + { + $dataClass = new class extends Data { + #[DataCollectionOf(TestValidationDataWithCollectionNestedDataWithFieldReference::class)] + public DataCollection $collection; + }; + + DataValidationAsserter::for($dataClass) + ->assertOk([ + 'collection' => [ + ['nested' => ['check_string' => '0']], + ], + ]) + ->assertErrors([ + 'collection' => [ + ['nested' => ['check_string' => '1']], + ], + ]) + ->assertRules( + rules: [ + 'collection' => ['present', 'array'], + 'collection.0.nested' => ['required', 'array'], + 'collection.0.nested.check_string' => ['required', 'boolean'], + 'collection.0.nested.string' => ['string', 'required_if:collection.*.nested.check_string,true'], + ], + payload: [ + 'collection' => [ + ['nested' => ['check_string' => '1']], + ], + ] + ); + } + + public function testCanReferenceToTheRootValidatedObjectInNestedData(): void + { + $dataClass = new class extends Data { + public bool $check_string; + + public TestDataWithRootReferenceFieldValidationAttribute $nested; + }; + + DataValidationAsserter::for($dataClass) + ->assertOk([ + 'check_string' => '0', + 'nested' => ['something'], + ]) + ->assertErrors([ + 'check_string' => '1', + 'nested' => ['something'], + ]) + ->assertRules( + rules: [ + 'check_string' => ['required', 'boolean'], + 'nested' => ['required', 'array'], + 'nested.string' => ['string', 'required_if:check_string,true'], + ], + payload: [ + 'nested' => ['check_string' => '1'], + ] + ); + } + + public function testWillValidateACollection(): void + { + $dataClass = new class extends Data { + #[DataCollectionOf(SimpleData::class)] + public DataCollection $collection; + }; + + DataValidationAsserter::for($dataClass) + ->assertOk([ + 'collection' => [ + ['string' => 'Never Gonna'], + ['string' => 'Give You Up'], + ], + ]) + ->assertOk(['collection' => []]) + ->assertErrors(['collection' => null]) + ->assertErrors([]) + ->assertErrors(['collection' => ['strings', 'here', 'instead', 'of', 'arrays']]) + ->assertErrors([ + 'collection' => [ + ['other_string' => 'Hello World'], + ], + ]) + ->assertRules([ + 'collection' => ['present', 'array'], + ]) + ->assertRules([ + 'collection' => ['present', 'array'], + 'collection.0.string' => ['required', 'string'], + ], [ + 'collection' => [[]], + ]); + } + + public function testWillValidateCollectionWithExplicitRequire(): void + { + $dataClass = new class extends Data { + #[Required] + #[DataCollectionOf(SimpleData::class)] + public DataCollection $collection; + }; + + DataValidationAsserter::for($dataClass) + ->assertOk([ + 'collection' => [ + ['string' => 'Never Gonna'], + ['string' => 'Give You Up'], + ], + ]) + ->assertErrors(['collection' => []]) + ->assertErrors(['collection' => null]) + ->assertErrors([]) + ->assertErrors([ + 'collection' => [ + ['other_string' => 'Hello World'], + ], + ]) + // A declared requiring rule already requires the key, so no inferred present rule is added. + ->assertRules([ + 'collection' => ['array', 'required'], + ]) + ->assertRules([ + 'collection' => ['array', 'required'], + 'collection.0.string' => ['required', 'string'], + ], [ + 'collection' => [[]], + ]); + } + + public function testWillValidateACollectionWithExtraAttributes(): void + { + $dataClass = new class extends Data { + #[DataCollectionOf(SimpleDataWithExplicitValidationRuleAttributeData::class)] + #[Min(2)] + public DataCollection $collection; + }; + + DataValidationAsserter::for($dataClass) + ->assertOk([ + 'collection' => [ + ['email' => 'ruben@spatie.be'], + ['email' => 'freek@spatie.be'], + ], + ]) + ->assertErrors([ + 'collection' => [ + ['email' => 'not-an'], + ['email' => 'email-address'], + ], + ]) + ->assertErrors(['collection' => []]) + ->assertRules([ + 'collection' => ['present', 'array', 'min:2'], + ]) + ->assertRules([ + 'collection' => ['present', 'array', 'min:2'], + 'collection.0.email' => ['required', 'string', 'email:rfc'], + ], [ + 'collection' => [[]], + ]); + } + + public function testWillValidateANullableCollection(): void + { + $dataClass = new class extends Data { + #[DataCollectionOf(SimpleData::class)] + public ?DataCollection $collection; + }; + + DataValidationAsserter::for($dataClass) + ->assertOk([ + 'collection' => [ + ['string' => 'Never Gonna'], + ['string' => 'Give You Up'], + ], + ]) + ->assertOk(['collection' => []]) + ->assertOk(['collection' => null]) + ->assertOk([]) + ->assertErrors([ + 'collection' => [ + ['other_string' => 'Hello World'], + ], + ]) + ->assertRules(['collection' => ['nullable', 'array']], payload: []) + ->assertRules(['collection' => ['nullable', 'array']], payload: ['collection' => null]) + // A nullable collection may be omitted, so it has no present rule (README). + ->assertRules([ + 'collection' => ['nullable', 'array'], + 'collection.0.string' => ['required', 'string'], + ], payload: [ + 'collection' => [[]], + ]); + } + + public function testWillValidateAnOptionalCollection(): void + { + $dataClass = new class extends Data { + #[DataCollectionOf(SimpleData::class)] + public Optional|DataCollection $collection; + }; + + DataValidationAsserter::for($dataClass) + ->assertOk([ + 'collection' => [ + ['string' => 'Never Gonna'], + ['string' => 'Give You Up'], + ], + ]) + ->assertOk(['collection' => []]) + ->assertOk([]) + ->assertErrors(['collection' => null]) + ->assertErrors([ + 'collection' => [ + ['other_string' => 'Hello World'], + ], + ]) + // With sometimes, a present rule would only run when the key exists, so none is added. + ->assertRules([ + 'collection' => ['sometimes', 'array'], + ], payload: ['collection' => null]) + ->assertRules([ + 'collection' => ['sometimes', 'array'], + 'collection.0.string' => ['required', 'string'], + ], payload: [ + 'collection' => [[]], + ]); + } + + public function testCanOverwriteCollectionClassRules(): void + { + $dataClass = new class extends Data { + #[DataCollectionOf(SimpleData::class)] + public DataCollection $collection; + + /** + * Get the validation rules. + */ + public static function rules(): array + { + return [ + 'collection' => ['array', 'min:1', 'max:2'], + ]; + } + }; + + DataValidationAsserter::for($dataClass) + ->assertOk([ + 'collection' => [ + ['string' => 'Never Gonna'], + ['string' => 'Give You Up'], + ], + ]) + ->assertOk([ + 'collection' => [ + ['string' => 'Never Gonna'], + ], + ]) + ->assertErrors([ + 'collection' => [ + ['string' => 'Never Gonna'], + ['string' => 'Give You Up'], + ['string' => 'Never Gonna'], + ], + ]) + ->assertErrors(['collection' => []]) + ->assertRules([ + 'collection' => ['array', 'min:1', 'max:2'], + ], payload: []) + ->assertRules([ + 'collection' => ['array', 'min:1', 'max:2'], + 'collection.0.string' => ['required', 'string'], + ], payload: [ + 'collection' => [[]], + ]); + } + + public function testCanOverwriteCollectionItemRulesWithWildcardRules(): void + { + // https://github.com/spatie/laravel-data/issues/1121 + $dataClass = new class extends Data { + /** @var array */ + public array $years; + + /** + * Get the validation rules. + */ + public static function rules(): array + { + return [ + 'years' => ['required', 'array'], + 'years.*' => ['date_format:Y', 'required'], + ]; + } + }; + + DataValidationAsserter::for($dataClass) + ->assertOk(['years' => ['2025']]) + ->assertErrors(['years' => ['not-a-year']]) + ->assertRules([ + 'years' => ['required', 'array'], + ], payload: []) + ->assertRules([ + 'years' => ['required', 'array'], + 'years.0' => ['date_format:Y', 'required'], + ], payload: [ + 'years' => ['2025'], + ]); + } + + // Complex examples + + public function testCanNestDataInCollections(): void + { + $dataClass = new class extends Data { + #[DataCollectionOf(CollectionClassA::class)] + public DataCollection $collection; + }; + + DataValidationAsserter::for($dataClass) + ->assertOk(['collection' => [['nested' => ['string' => 'Hello World']]]]) + ->assertErrors(['collection' => [['nested' => null]]]) + ->assertErrors(['collection' => [['nested' => []]]]) + ->assertRules([ + 'collection' => ['present', 'array'], + ]) + ->assertRules([ + 'collection' => ['present', 'array'], + 'collection.0.nested' => ['required', 'array'], + 'collection.0.nested.string' => ['required', 'string'], + ], [ + 'collection' => [[]], + ]); + } + + public function testCanNestNullableDataInCollections(): void + { + $dataClass = new class extends Data { + #[DataCollectionOf(CollectionClassTable::class)] + public DataCollection $collection; + }; + + DataValidationAsserter::for($dataClass) + ->assertOk(['collection' => [['nested' => ['string' => 'Hello World']]]]) + ->assertRules([ + 'collection' => ['present', 'array'], + ]) + ->assertRules([ + 'collection' => ['present', 'array'], + 'collection.0.nested' => ['nullable', 'array'], + ], [ + 'collection' => [[]], + ]) + ->assertRules([ + 'collection' => ['present', 'array'], + 'collection.0.nested' => ['nullable', 'array'], + ], [ + 'collection' => [['nested' => null]], + ]) + ->assertRules([ + 'collection' => ['present', 'array'], + 'collection.0.nested' => ['nullable', 'array'], + 'collection.0.nested.string' => ['required', 'string'], + ], [ + 'collection' => [['nested' => []]], + ]); + } + + public function testCanNestOptionalDataInCollections(): void + { + $dataClass = new class extends Data { + #[DataCollectionOf(CollectionClassC::class)] + public DataCollection $collection; + }; + + DataValidationAsserter::for($dataClass) + ->assertOk(['collection' => [['nested' => ['string' => 'Hello World']]]]) + ->assertErrors(['collection' => [['nested' => null]]]) + ->assertErrors(['collection' => [['nested' => []]]]) + ->assertRules([ + 'collection' => ['present', 'array'], + ]) + ->assertRules([ + 'collection' => ['present', 'array'], + 'collection.0.nested' => ['sometimes', 'array'], + ], [ + 'collection' => [[]], + ]) + ->assertRules([ + 'collection' => ['present', 'array'], + 'collection.0.nested' => ['sometimes', 'array'], + 'collection.0.nested.string' => ['required', 'string'], + ], [ + 'collection' => [['nested' => null]], + ]) + ->assertRules([ + 'collection' => ['present', 'array'], + 'collection.0.nested' => ['sometimes', 'array'], + 'collection.0.nested.string' => ['required', 'string'], + ], [ + 'collection' => [['nested' => []]], + ]); + } + + public function testCanNestDataInCollectionsUsingRelativeRuleGeneration(): void + { + $dataClass = new class extends Data { + #[DataCollectionOf(DummyDataWithContextOverwrittenValidationRules::class)] + public DataCollection $collection; + }; + + DataValidationAsserter::for($dataClass) + ->assertOk([ + 'collection' => [ + ['string' => 'Hello World', 'validate_as_email' => false], + ['string' => 'hello@world.test', 'validate_as_email' => true], + ], + ]) + ->assertErrors([ + 'collection' => [ + ['string' => 'Invalid Email', 'validate_as_email' => true], + ['string' => 'Hello World', 'validate_as_email' => false], + ['string' => 'Invalid Email', 'validate_as_email' => true], + ], + ], [ + 'collection.0.string' => [__('validation.email', ['attribute' => 'collection.0.string'])], + 'collection.2.string' => [__('validation.email', ['attribute' => 'collection.2.string'])], + ]) + ->assertRules( + [ + 'collection' => ['present', 'array'], + 'collection.0.string' => ['required', 'string'], + 'collection.0.validate_as_email' => ['boolean', 'required'], + 'collection.1.string' => ['required', 'string', 'email'], + 'collection.1.validate_as_email' => ['boolean', 'required'], + ], + [ + 'collection' => [ + ['string' => 'Hello World', 'validate_as_email' => false], + ['string' => 'hello@world.test', 'validate_as_email' => true], + ], + ] + ); + } + + public function testSupportsRequiredWithoutValidationForOptionalCollections(): void + { + $dataClass = new class extends Data { + #[RequiredWithout('someOtherData')] + #[DataCollectionOf(SimpleData::class)] + public DataCollection|Optional $someData; + + #[RequiredWithout('someData')] + #[DataCollectionOf(SimpleData::class)] + public DataCollection|Optional $someOtherData; + }; + + DataValidationAsserter::for($dataClass) + ->assertRules( + [ + 'someData' => [ + 'array', + 'required_without:someOtherData', + ], + 'someOtherData' => [ + 'array', + 'required_without:someData', + ], + ], + ) + ->assertOk([ + 'someData' => [['string' => 'Hello World']], + ]) + ->assertOk([ + 'someOtherData' => [['string' => 'Hello World']], + ]) + ->assertOk([ + 'someData' => [['string' => 'Hello World']], + 'someOtherData' => [['string' => 'Hello World']], + ]) + ->assertErrors([]); + } + + public function testSupportsRequiredWithoutValidationForNullableCollections(): void + { + $dataClass = new class extends Data { + #[RequiredWithout('someOtherData')] + #[DataCollectionOf(SimpleData::class)] + public ?DataCollection $someData; + + #[RequiredWithout('someData')] + #[DataCollectionOf(SimpleData::class)] + public ?DataCollection $someOtherData; + }; + + DataValidationAsserter::for($dataClass) + ->assertRules( + [ + 'someData' => [ + 'nullable', + 'array', + 'required_without:someOtherData', + ], + 'someOtherData' => [ + 'nullable', + 'array', + 'required_without:someData', + ], + ], + [] + ); + } + + public function testCanNestDataInClassesInsideCollectionsUsingRelativeRuleGeneration(): void + { + $dataClass = new class extends Data { + #[DataCollectionOf(CollectionClassK::class)] + public DataCollection $collection; + }; + + DataValidationAsserter::for($dataClass) + ->assertRules([ + 'collection' => ['present', 'array'], + 'collection.0.nested' => ['required', 'array'], + 'collection.0.nested.string' => ['required', 'string'], + 'collection.0.nested.validate_as_email' => ['boolean', 'required'], + 'collection.1.nested' => ['required', 'array'], + 'collection.1.nested.string' => ['required', 'string', 'email'], + 'collection.1.nested.validate_as_email' => ['boolean', 'required'], + ], [ + 'collection' => [ + ['nested' => ['string' => 'Hello World', 'validate_as_email' => false]], + ['nested' => ['string' => 'hello@world.test', 'validate_as_email' => true]], + ], + ]) + ->assertOk([ + 'collection' => [ + ['nested' => ['string' => 'Hello World', 'validate_as_email' => false]], + ['nested' => ['string' => 'hello@world.test', 'validate_as_email' => true]], + ], + ]) + ->assertErrors([ + 'collection' => [ + ['nested' => ['string' => 'Invalid Email', 'validate_as_email' => true]], + ['nested' => ['string' => 'Hello World', 'validate_as_email' => false]], + ['nested' => ['string' => 'Invalid Email', 'validate_as_email' => true]], + ], + ], [ + 'collection.0.nested.string' => [__('validation.email', ['attribute' => 'collection.0.nested.string'])], + 'collection.2.nested.string' => [__('validation.email', ['attribute' => 'collection.2.nested.string'])], + ]); + } + + public function testCanNestDataInDeepCollectionsUsingRelativeRuleGeneration(): void + { + $dataClass = new class extends Data { + #[DataCollectionOf(ValidationTestNestedDataWithContextOverwrittenRules::class)] + public DataCollection $collection; + }; + + DataValidationAsserter::for($dataClass) + ->assertRules([ + 'collection' => ['present', 'array'], + 'collection.0.validate_as_email' => ['boolean', 'required'], + 'collection.0.string' => ['required', 'string'], + 'collection.0.items' => ['array', 'required'], + 'collection.0.items.0.deep_validate_as_email' => ['boolean', 'required'], + 'collection.0.items.0.deep_string' => ['required', 'string'], + 'collection.0.items.1.deep_validate_as_email' => ['boolean', 'required'], + 'collection.0.items.1.deep_string' => ['required', 'string', 'email'], + ], [ + 'collection' => [ + [ + 'string' => 'Hello World', + 'validate_as_email' => false, + 'items' => [ + ['deep_string' => 'Hello World', 'deep_validate_as_email' => false], + ['deep_string' => 'hello@world.test', 'deep_validate_as_email' => true], + ], + ], + ], + ]) + ->assertOk([ + 'collection' => [ + [ + 'string' => 'Hello World', + 'validate_as_email' => false, + 'items' => [ + ['deep_string' => 'Hello World', 'deep_validate_as_email' => false], + ['deep_string' => 'hello@world.test', 'deep_validate_as_email' => true], + ], + ], + ], + ]) + ->assertErrors([ + 'collection' => [ + [ + 'string' => 'Hello World', + 'validate_as_email' => false, + 'items' => [ + ['deep_string' => 'Invalid Email', 'deep_validate_as_email' => true], + ['deep_string' => 'Hello World', 'deep_validate_as_email' => false], + ['deep_string' => 'hello@world.test', 'deep_validate_as_email' => true], + ], + ], + [ + 'string' => 'Invalid Email', + 'validate_as_email' => true, + 'items' => [ + ['deep_string' => 'Invalid Email', 'deep_validate_as_email' => true], + ['deep_string' => 'Hello World', 'deep_validate_as_email' => false], + ['deep_string' => 'hello@world.test', 'deep_validate_as_email' => true], + ], + ], + ], + ], [ + // Class rules apply in declaration order, so the rule that replaces collection.1.string follows the generated ones. + 'collection.0.items.0.deep_string' => [__('validation.email', ['attribute' => 'collection.0.items.0.deep string'])], + 'collection.1.items.0.deep_string' => [__('validation.email', ['attribute' => 'collection.1.items.0.deep string'])], + 'collection.1.string' => [__('validation.email', ['attribute' => 'collection.1.string'])], + ]); + } + + public function testCanNestDataUsingRelativeRuleGeneration(): void + { + $dataClass = new class extends Data { + public DummyDataWithContextOverwrittenValidationRules $nested; + }; + + $payload = [ + 'nested' => [ + 'string' => 'Hello World', + 'validate_as_email' => true, + ], + ]; + + DataValidationAsserter::for($dataClass) + ->assertRules([ + 'nested' => ['required', 'array'], + 'nested.string' => ['required', 'string', 'email'], + 'nested.validate_as_email' => ['boolean', 'required'], + ], $payload) + ->assertErrors($payload); + } + + public function testCorrectlyInjectsContextInTheRulesMethod(): void + { + $dataClass = new class extends Data { + public string $property; + + public NestedClassN $nested; + + /** + * Check the context passed to the root rules. + */ + public static function rules(ValidationContext $context): array + { + $correct = $context->payload['property'] === 'Root' + && $context->fullPayload['property'] === 'Root' + && $context->path->isRoot(); + + if (! $correct) { + throw new Exception('Should not end up here'); + } + + return []; + } + }; + + $payload = [ + 'property' => 'Root', + 'nested' => [ + 'property' => 'N', + 'data' => [ + 'property' => 'J', + ], + 'collection' => [ + [ + 'property' => 'M', + 'nested' => [ + 'property' => 'K', + ], + 'collection' => [ + [ + 'property' => 'L', + ], + ], + ], + ], + ], + ]; + + DataValidationAsserter::for($dataClass) + ->assertRules([ + 'property' => ['required', 'string'], + 'nested' => ['required', 'array'], + 'nested.property' => ['required', 'string'], + 'nested.data' => ['required', 'array'], + 'nested.data.property' => ['required', 'string'], + 'nested.collection' => ['present', 'array'], + 'nested.collection.0.property' => ['required', 'string'], + 'nested.collection.0.nested' => ['required', 'array'], + 'nested.collection.0.nested.property' => ['required', 'string'], + 'nested.collection.0.collection' => ['present', 'array'], + 'nested.collection.0.collection.0.property' => ['required', 'string'], + ], $payload); + } + + public function testWillMergeOverwrittenRulesOnInheritedDataObjects(): void + { + $data = new class extends Data { + public SimpleDataWithOverwrittenRules $nested; + + /** @var DataCollection */ + public DataCollection $collection; + }; + + $payload = [ + 'nested' => ['string' => 'test'], + 'collection' => [ + ['string' => 'test'], + ], + ]; + + DataValidationAsserter::for($data)->assertRules([ + 'nested' => ['required', 'array'], + 'nested.string' => ['string', 'required', 'min:10', 'max:100'], + 'collection' => ['present', 'array'], + 'collection.0.string' => ['string', 'required', 'min:10', 'max:100'], + ], $payload)->assertErrors($payload); + } + + public function testWillReduceAttributeRulesToHypervelRulesInTheEnd(): void + { + $dataClass = new class extends Data { + public int $property; + + public static Closure $where; + + /** + * Get the validation rules. + */ + public static function rules(): array + { + return [ + 'property' => [ + new IntegerType, + new Exists('table', where: self::$where), + ], + ]; + } + }; + + $dataClass::$where = fn (Builder $query): Builder => $query->where('is_admin', true); + + DataValidationAsserter::for($dataClass)->assertRules([ + 'property' => [ + 'integer', + (new HypervelExists('table'))->where($dataClass::$where), + ], + ]); + } + + public function testCanUseDatabaseConstraintsWithExistsValidation(): void + { + $dataClass = new class extends Data { + public int $property; + + /** + * Get the validation rules. + */ + public static function rules(): array + { + return [ + 'property' => [ + new Exists('users', where: [ + new WhereConstraint('status', 'active'), + new WhereNullConstraint('deleted_at'), + ]), + ], + ]; + } + }; + + DataValidationAsserter::for($dataClass)->assertRules([ + 'property' => [ + (new HypervelExists('users')) + ->where('status', 'active') + ->whereNull('deleted_at'), + ], + ]); + } + + public function testCanUseMultipleDatabaseConstraintsWithExistsValidation(): void + { + $dataClass = new class extends Data { + public int $userId; + + /** + * Get the validation rules. + */ + public static function rules(): array + { + return [ + 'userId' => [ + new Exists('users', where: [ + new WhereConstraint('active', true), + new WhereNotConstraint('name', 'Unlucky'), + new WhereInConstraint('role', ['admin', 'user']), + new WhereNotInConstraint('type', ['guest', 'temp']), + new WhereNullConstraint('deleted_at'), + new WhereNotNullConstraint('email_verified_at'), + ]), + ], + ]; + } + }; + + DataValidationAsserter::for($dataClass)->assertRules([ + 'userId' => [ + (new HypervelExists('users')) + ->where('active', true) + ->whereNot('name', 'Unlucky') + ->whereIn('role', ['admin', 'user']) + ->whereNotIn('type', ['guest', 'temp']) + ->whereNull('deleted_at') + ->whereNotNull('email_verified_at'), + ], + ]); + } + + public function testCanCombineDatabaseConstraintsWithClosureConstraintsInExistsValidation(): void + { + $this->freezeTime(); + + $dataClass = new class extends Data { + public int $userId; + + /** + * Get the validation rules. + */ + public static function rules(): array + { + return [ + 'userId' => [ + new Exists('users', where: [ + new WhereConstraint('active', true), + fn (Builder $query): Builder => $query->where('created_at', '>', now()->subYear()), + ]), + ], + ]; + } + }; + + DataValidationAsserter::for($dataClass)->assertRules([ + 'userId' => [ + (new HypervelExists('users')) + ->where('active', true) + ->where(fn (Builder $query): Builder => $query->where('created_at', '>', now()->subYear())), + ], + ]); + } + + public function testThrowsExceptionForInvalidDatabaseConstraintInExistsValidation(): void + { + $dataClass = new class extends Data { + public int $userId; + + /** + * Get the validation rules. + */ + public static function rules(): array + { + return [ + 'userId' => [ + new Exists('users', where: ['invalid_constraint']), + ], + ]; + } + }; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageIs('Each where item must be a DatabaseConstraint or Closure'); + + $dataClass::getValidationRules([]); + } + + public function testCanUseDatabaseConstraintsWithUniqueValidation(): void + { + $dataClass = new class extends Data { + public string $email; + + /** + * Get the validation rules. + */ + public static function rules(): array + { + return [ + 'email' => [ + new Unique('users', where: [ + new WhereConstraint('active', true), + new WhereNullConstraint('deleted_at'), + ]), + ], + ]; + } + }; + + DataValidationAsserter::for($dataClass)->assertRules([ + 'email' => [ + (new HypervelUnique('users')) + ->where('active', true) + ->whereNull('deleted_at'), + ], + ]); + } + + public function testCanUseMultipleDatabaseConstraintsWithUniqueValidation(): void + { + $dataClass = new class extends Data { + public string $email; + + /** + * Get the validation rules. + */ + public static function rules(): array + { + return [ + 'email' => [ + new Unique('users', where: [ + new WhereConstraint('active', true), + new WhereNotConstraint('name', 'Unlucky'), + new WhereInConstraint('role', ['admin', 'user']), + new WhereNotInConstraint('type', ['guest', 'temp']), + new WhereNullConstraint('deleted_at'), + new WhereNotNullConstraint('email_verified_at'), + ]), + ], + ]; + } + }; + + DataValidationAsserter::for($dataClass)->assertRules([ + 'email' => [ + (new HypervelUnique('users')) + ->where('active', true) + ->whereNot('name', 'Unlucky') + ->whereIn('role', ['admin', 'user']) + ->whereNotIn('type', ['guest', 'temp']) + ->whereNull('deleted_at') + ->whereNotNull('email_verified_at'), + ], + ]); + } + + public function testCanCombineDatabaseConstraintsWithClosureConstraintsInUniqueValidation(): void + { + $this->freezeTime(); + + $dataClass = new class extends Data { + public string $email; + + /** + * Get the validation rules. + */ + public static function rules(): array + { + return [ + 'email' => [ + new Unique('users', where: [ + new WhereConstraint('active', true), + fn (Builder $query): Builder => $query->where('created_at', '>', now()->subYear()), + ]), + ], + ]; + } + }; + + DataValidationAsserter::for($dataClass)->assertRules([ + 'email' => [ + (new HypervelUnique('users')) + ->where('active', true) + ->where(fn (Builder $query): Builder => $query->where('created_at', '>', now()->subYear())), + ], + ]); + } + + public function testCanUseDatabaseConstraintsWithUniqueValidationWhileMaintainingIgnoreFunctionality(): void + { + $dataClass = new class extends Data { + public string $email; + + /** + * Get the validation rules. + */ + public static function rules(): array + { + return [ + 'email' => [ + new Unique('users', ignore: 5, where: [ + new WhereConstraint('active', true), + new WhereNotConstraint('deleted_at', null), + ]), + ], + ]; + } + }; + + DataValidationAsserter::for($dataClass)->assertRules([ + 'email' => [ + (new HypervelUnique('users')) + ->ignore(5) + ->where('active', true) + ->whereNot('deleted_at', null), + ], + ]); + } + + public function testThrowsExceptionForInvalidDatabaseConstraintInUniqueValidation(): void + { + $dataClass = new class extends Data { + #[Unique('users', where: ['invalid_constraint'])] + public string $email; + }; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageIs('Each where item must be a DatabaseConstraint or Closure'); + + $dataClass::getValidationRules([]); + } + + public function testCanUseExternalReferenceAsDatabaseConstraintValue(): void + { + $dataClass = new class extends Data { + #[Unique('users', where: [ + new WhereConstraint('is_active', new RouteParameterReference('active')), + ])] + public string $email; + }; + + $this->bindRouteParameters(['active' => true]); + + DataValidationAsserter::for($dataClass)->assertRules([ + 'email' => [ + 'required', + 'string', + 'unique:users,NULL,NULL,id,is_active,"1"', + ], + ]); + } + + public function testCanReferenceRouteParametersAsValuesWithinRules(): void + { + $dataClass = new class extends Data { + #[Unique('posts', ignore: new RouteParameterReference('post_id'))] + public int $property; + }; + + $this->bindRouteParameters(['post_id' => '69']); + + DataValidationAsserter::for($dataClass)->assertRules([ + 'property' => [ + 'required', + 'integer', + 'unique:posts,NULL,"69",id', + ], + ]); + } + + public function testCanReferenceRouteModelsWithAPropertyAsValuesWithinRules(): void + { + $dataClass = new class extends Data { + #[Unique('posts', ignore: new RouteParameterReference('post', 'id'))] + public int $property; + }; + + $this->bindRouteParameters(['post' => new DummyModel(['id' => 69])]); + + DataValidationAsserter::for($dataClass)->assertRules([ + 'property' => [ + 'required', + 'integer', + 'unique:posts,NULL,"69",id', + ], + ]); + } + + public function testCanReferenceTheCurrentLoggedInUserAsValuesWithinRules(): void + { + $this->actingAs($user = (new User)->forceFill(['id' => 69])); + + $dataClass = new class extends Data { + #[Unique('users', ignore: new AuthenticatedUserReference)] + public int $property; + }; + + $this->assertEquals([ + 'property' => [ + 'required', + 'integer', + Rule::unique('users')->ignore($user), + ], + ], $dataClass::getValidationRules([])); + } + + public function testCanReferenceAContainerDependencyAsValuesWithinRules(): void + { + $this->app->bind('max-allowed-size', fn (): int => 100); + + $dataClass = new class extends Data { + #[Max(value: new ContainerReference('max-allowed-size'))] + public int $property; + }; + + DataValidationAsserter::for($dataClass)->assertRules([ + 'property' => [ + 'required', + 'integer', + 'max:100', + ], + ]); + } + + public function testCanSetTheValidatorToStopOnTheFirstFailure(): void + { + $dataClass = new class extends Data { + #[Min(10)] + public int $propertyA; + + #[Min(10)] + public int $propertyB; + + /** + * Stop validating after the first failure. + */ + public static function stopOnFirstFailure(): bool + { + return true; + } + }; + + DataValidationAsserter::for($dataClass)->assertRules([ + 'propertyA' => ['required', 'integer', 'min:10'], + 'propertyB' => ['required', 'integer', 'min:10'], + ])->assertErrors([ + 'propertyA' => 0, + 'propertyB' => 0, + ], ['propertyA' => [__('validation.min.numeric', ['attribute' => 'property a', 'min' => '10'])]]); + } + + public function testCanManuallySetValidationMessages(): void + { + $data = new class extends Data { + public string $name; + + public string $song; + + /** + * Get the validation messages. + */ + public static function messages(): array + { + return [ + 'name.required' => 'Fix it Rick!', + ]; + } + }; + + DataValidationAsserter::for($data) + ->assertMessages( + messages: ['name.required' => 'Fix it Rick!'], + payload: ['song' => 'Never Gonna Give You Up'], + ) + ->assertErrors( + payload: ['song' => 'Never Gonna Give You Up'], + errors: ['name' => ['Fix it Rick!']] + ); + + $data = new class extends Data { + public string $name; + + public string $song; + + /** + * Get the validation messages. + */ + public static function messages(): array + { + return [ + 'name' => ['required' => 'Fix it Rick!'], + ]; + } + }; + + DataValidationAsserter::for($data) + ->assertMessages( + messages: ['name' => ['required' => 'Fix it Rick!']], + payload: ['song' => 'Never Gonna Give You Up'], + ) + ->assertErrors( + payload: ['song' => 'Never Gonna Give You Up'], + errors: ['name' => ['Fix it Rick!']] + ); + + $data = new class extends Data { + public string $name; + + public string $song; + + /** + * Get the validation messages. + */ + public static function messages(): array + { + return [ + 'required' => 'Fix it Rick!', + ]; + } + }; + + DataValidationAsserter::for($data) + ->assertMessages( + messages: ['*.required' => 'Fix it Rick!'], + payload: [], + ) + ->assertErrors( + payload: [], + errors: ['name' => ['Fix it Rick!'], 'song' => ['Fix it Rick!']] + ); + } + + public function testCanManuallySetMessagesNested(): void + { + DataValidationAsserter::for(new class extends Data { + public TestNestedValidationMessagesDataA $nested; + }) + ->assertMessages( + messages: ['nested.name.required' => 'Fix it Rick!'], + payload: ['nested' => ['song' => 'Never Gonna Give You Up']], + ) + ->assertErrors( + payload: ['nested' => ['song' => 'Never Gonna Give You Up']], + errors: ['nested.name' => ['Fix it Rick!']] + ); + + DataValidationAsserter::for(new class extends Data { + public TestNestedValidationMessagesDataB $nested; + }) + ->assertMessages( + messages: ['nested.name' => ['required' => 'Fix it Rick!']], + payload: ['nested' => ['song' => 'Never Gonna Give You Up']], + ) + ->assertErrors( + payload: ['nested' => ['song' => 'Never Gonna Give You Up']], + errors: ['nested.name' => ['Fix it Rick!']] + ); + + DataValidationAsserter::for(new class extends Data { + public TestNestedValidationMessagesDataC $nested; + }) + ->assertMessages( + messages: ['nested.*.required' => 'Fix it Rick!'], + payload: ['nested' => []], + ) + ->assertErrors( + payload: ['nested' => []], + errors: [ + 'nested' => [__('validation.required', ['attribute' => 'nested'])], + 'nested.name' => ['Fix it Rick!'], + 'nested.song' => ['Fix it Rick!'], + ] + ); + + DataValidationAsserter::for(new class extends Data { + public TestNestedValidationMessagesDataC $nested; + + /** + * Get the validation messages. + */ + public static function messages(mixed ...$args): array + { + return [ + 'required' => 'Fix it Rick root!', + ]; + } + }) + ->assertMessages( + messages: [ + '*.required' => 'Fix it Rick root!', + 'nested.*.required' => 'Fix it Rick!', + ], + payload: ['nested' => []], + ) + ->assertErrors( + payload: ['nested' => []], + errors: [ + 'nested' => ['Fix it Rick root!'], + 'nested.name' => ['Fix it Rick!'], + 'nested.song' => ['Fix it Rick!'], + ] + ); + } + + public function testCanManuallySetMessagesInCollections(): void + { + DataValidationAsserter::for(new class extends Data { + #[DataCollectionOf(TestCollectionValidationMessagesDataA::class)] + public DataCollection $collection; + }) + ->assertMessages( + messages: ['collection.*.name.required' => 'Fix it Rick!'], + payload: ['collection' => [['song' => 'Never Gonna Give You Up']]], + ) + ->assertErrors( + payload: [ + 'collection' => [ + ['song' => 'Never Gonna Give You Up'], + ['song' => 'Together Forever'], + ], + ], + errors: [ + 'collection.0.name' => ['Fix it Rick!'], + 'collection.1.name' => ['Fix it Rick!'], + ] + ); + + DataValidationAsserter::for(new class extends Data { + #[DataCollectionOf(TestCollectionValidationMessagesDataB::class)] + public DataCollection $collection; + }) + ->assertMessages( + messages: ['collection.*.name' => ['required' => 'Fix it Rick!']], + payload: ['collection' => [['song' => 'Never Gonna Give You Up']]], + ) + ->assertErrors( + payload: [ + 'collection' => [ + ['song' => 'Never Gonna Give You Up'], + ['song' => 'Together Forever'], + ], + ], + errors: [ + 'collection.0.name' => ['Fix it Rick!'], + 'collection.1.name' => ['Fix it Rick!'], + ] + ); + + DataValidationAsserter::for(new class extends Data { + #[DataCollectionOf(TestCollectionValidationMessagesDataC::class)] + public DataCollection $collection; + }) + ->assertMessages( + messages: ['collection.*.*.required' => 'Fix it Rick!'], + payload: ['collection' => [['song' => 'Never Gonna Give You Up']]], + ) + ->assertErrors( + payload: [ + 'collection' => [ + ['song' => 'Never Gonna Give You Up'], + ['song' => 'Together Forever'], + ], + ], + errors: [ + 'collection.0.name' => ['Fix it Rick!'], + 'collection.1.name' => ['Fix it Rick!'], + ] + ); + } + + public function testCanManuallySetMessagesInDoubleNestedCollectionsYeahThisFailedOnce(): void + { + DataValidationAsserter::for(new class extends Data { + #[DataCollectionOf(TestDoubleNestedCollectionValidationMessagesInitialDataA::class)] + public DataCollection $collection; + }) + ->assertMessages( + messages: ['collection.*.nestedCollection.*.name.required' => 'Fix it Rick!'], + payload: ['collection' => [['nestedCollection' => ['collection' => [['song' => 'Never Gonna Give You Up']]]]]], + ) + ->assertErrors( + payload: [ + 'collection' => [ + [ + 'nestedCollection' => [ + ['song' => 'Never Gonna Give You Up'], + ['song' => 'Giving up on love'], + ], + ], + ['nestedCollection' => [['song' => 'Together Forever']]], + ], + ], + errors: [ + 'collection.0.nestedCollection.0.name' => ['Fix it Rick!'], + 'collection.0.nestedCollection.1.name' => ['Fix it Rick!'], + 'collection.1.nestedCollection.0.name' => ['Fix it Rick!'], + ] + ); + } + + public function testCanResolveValidationDependenciesForMessages(): void + { + FakeInjectable::setup('Rick Astley'); + + $data = new class extends Data { + public string $name; + + /** + * Get the validation messages from an injected dependency. + */ + public static function messages(FakeInjectable $injectable): array + { + return [ + 'name.required' => $injectable->value === 'Rick Astley' ? 'Fix it Rick!' : 'Fix it!', + ]; + } + }; + + DataValidationAsserter::for($data)->assertErrors( + payload: ['name' => null], + errors: ['name' => ['Fix it Rick!']] + ); + } + + public function testCanManuallySetValidationAttributes(): void + { + $data = new class extends Data { + public string $name; + + /** + * Get the validation attribute names. + */ + public static function attributes(): array + { + return [ + 'name' => 'rickster', + ]; + } + }; + + DataValidationAsserter::for($data) + ->assertAttributes([ + 'name' => 'rickster', + ]) + ->assertErrors( + payload: ['name' => null], + errors: ['name' => [__('validation.required', ['attribute' => 'rickster'])]] + ); + } + + public function testCanManuallySetNestedValidationAttributes(): void + { + $data = new class extends Data { + public TestNestedValidationAttributesData $nested; + }; + + DataValidationAsserter::for($data) + ->assertAttributes( + ['nested.name' => 'rickster'], + payload: ['nested' => ['name' => null]] + ) + ->assertErrors( + payload: ['nested' => ['name' => null]], + errors: ['nested.name' => [__('validation.required', ['attribute' => 'rickster'])]] + ); + } + + public function testCanManuallySetCollectedValidationAttributes(): void + { + // Upstream skips this case as unsupported; the validator applies wildcard attribute names to each item. + $data = new class extends Data { + #[DataCollectionOf(TestCollectedValidationAttributesData::class)] + public DataCollection $collection; + }; + + DataValidationAsserter::for($data) + ->assertAttributes( + ['collection.*.name' => 'rickster'], + payload: ['collection' => [['name' => null]]], + ) + ->assertErrors( + payload: ['collection' => [['name' => null]]], + errors: ['collection.0.name' => [__('validation.required', ['attribute' => 'rickster'])]] + ); + } + + public function testCanResolveValidationDependenciesForAttributes(): void + { + FakeInjectable::setup('Rick Astley'); + + $data = new class extends Data { + public string $name; + + /** + * Get the validation attribute names from an injected dependency. + */ + public static function attributes(FakeInjectable $injectable): array + { + return [ + 'name' => $injectable->value === 'Rick Astley' ? 'rickster' : 'someone', + ]; + } + }; + + DataValidationAsserter::for($data)->assertErrors( + payload: ['name' => null], + errors: ['name' => [__('validation.required', ['attribute' => 'rickster'])]] + ); + } + + public function testCanManuallySetTheRedirectUrl(): void + { + $data = new class extends Data { + public string $name; + + /** + * Get the redirect URL for failed validation. + */ + public static function redirect(): string + { + return '/never-given-up'; + } + }; + + // As with a Laravel FormRequest, the redirect is generated as an absolute URL. + DataValidationAsserter::for($data)->assertRedirect( + payload: ['name' => null], + redirect: 'http://localhost/never-given-up' + ); + } + + public function testCanResolveValidationDependenciesForRedirectUrl(): void + { + FakeInjectable::setup('Rick Astley'); + + $data = new class extends Data { + public string $name; + + /** + * Get the redirect URL from an injected dependency. + */ + public static function redirect(FakeInjectable $injectable): string + { + return $injectable->value === 'Rick Astley' ? '/never-given-up' : '/given-up'; + } + }; + + DataValidationAsserter::for($data)->assertRedirect( + payload: ['name' => null], + redirect: 'http://localhost/never-given-up' + ); + } + + public function testCanManuallySetTheRedirectRoute(): void + { + Route::get('/never-given-up', fn (): string => 'Never gonna give you up')->name('never-given-up'); + + $data = new class extends Data { + public string $name; + + /** + * Get the redirect route for failed validation. + */ + public static function redirectRoute(): string + { + return 'never-given-up'; + } + }; + + DataValidationAsserter::for($data)->assertRedirect( + payload: ['name' => null], + redirect: 'http://localhost/never-given-up' + ); + } + + public function testCanResolveValidationDependenciesForRedirectRoute(): void + { + FakeInjectable::setup('Rick Astley'); + + Route::get('/never-given-up', fn (): string => 'Never gonna give you up')->name('never-given-up'); + + $data = new class extends Data { + public string $name; + + /** + * Get the redirect route from an injected dependency. + */ + public static function redirectRoute(FakeInjectable $injectable): string + { + return $injectable->value === 'Rick Astley' ? 'never-given-up' : 'given-up'; + } + }; + + DataValidationAsserter::for($data)->assertRedirect( + payload: ['name' => null], + redirect: 'http://localhost/never-given-up' + ); + } + + public function testCanManuallySpecifyTheValidator(): void + { + $dataClass = new class extends Data { + public string $property; + + /** + * Remove every rule from the validator. + */ + public static function withValidator(Validator $validator): void + { + $validator->setRules([]); + } + }; + + DataValidationAsserter::for($dataClass)->assertOk([]); + } + + public function testCanResolveValidationDependenciesForErrorBag(): void + { + FakeInjectable::setup('Rick Astley'); + + $data = new class extends Data { + public string $name; + + /** + * Get the error bag from an injected dependency. + */ + public static function errorBag(FakeInjectable $injectable): string + { + return $injectable->value === 'Rick Astley' ? 'never-given-up' : 'given-up'; + } + }; + + DataValidationAsserter::for($data)->assertErrorBag( + payload: ['name' => null], + errorBag: 'never-given-up' + ); + } + + public function testCanValidateAPayloadForADataObjectWithoutCreatingOne(): void + { + $this->assertSame(['string' => 'Hello World'], SimpleData::validate(['string' => 'Hello World'])); + + try { + SimpleData::validate(['string' => 10]); + $this->fail('Expected the payload to fail validation.'); + } catch (ValidationException $exception) { + $this->assertSame([ + 'string' => [__('validation.string', ['attribute' => 'string'])], + ], $exception->errors()); + } + } + + public function testCanValidateAPayloadForADataObjectAndCreateOne(): void + { + $data = SimpleData::validateAndCreate(['string' => 'Hello World']); + + $this->assertSame('Hello World', $data->string); + + try { + SimpleData::validateAndCreate(['string' => 10]); + $this->fail('Expected the payload to fail validation.'); + } catch (ValidationException $exception) { + $this->assertSame([ + 'string' => [__('validation.string', ['attribute' => 'string'])], + ], $exception->errors()); + } + } + + public function testCanValidateAPayloadForADataObjectAndCreateOneUsingAMagicFromMethod(): void + { + $dataClass = new class extends Data { + public string $string; + + /** + * Create the data object from a request. + */ + public static function fromRequest(Request $request): static + { + $self = new self; + + $self->string = strtoupper($request->input('string')); + + return $self; + } + }; + + $data = $dataClass::validateAndCreate( + (new Request)->merge(['string' => 'hello world']), + ); + + $this->assertSame('HELLO WORLD', $data->string); + + try { + SimpleData::validateAndCreate(new Request); + $this->fail('Expected the request to fail validation.'); + } catch (ValidationException $exception) { + $this->assertSame([ + 'string' => [__('validation.required', ['attribute' => 'string'])], + ], $exception->errors()); + } + } + + public function testCanTheValidationRulesForADataObject(): void + { + $this->assertEquals([ + 'first' => ['required', 'string'], + 'second' => ['required', 'string'], + ], MultiData::getValidationRules([])); + + $this->assertEquals(['nested' => ['nullable', 'array']], NestedNullableData::getValidationRules(payload: [])); + + $this->assertEquals([ + 'nested' => ['nullable', 'array'], + 'nested.string' => ['required', 'string'], + ], NestedNullableData::getValidationRules(payload: ['nested' => []])); + } + + public function testCanApplyCustomRulesOntoArrayProperties(): void + { + // A readonly property must be promoted: PHP lets only the class itself assign it, so Hypervel + // rejects an unpromoted one when the class is first used. + $dataClass = new class([]) extends Data { + /** + * Create the data object. + */ + public function __construct( + #[Min(1)] + #[Max(5)] + public readonly array $emails, + ) { + } + + /** + * Get the validation rules. + */ + public static function rules(): array + { + return [ + 'emails.*' => ['email'], + ]; + } + }; + + $this->assertEquals([ + 'emails' => ['required', 'array', 'min:1', 'max:5'], + 'emails.*' => ['email'], + ], $dataClass::getValidationRules([])); + } + + public function testCanValidateDataWithCircularDependencies(): void + { + DataValidationAsserter::for(CircData::class) + ->assertRules([ + 'string' => ['required', 'string'], + 'ular' => ['nullable', 'array'], + ]); + + DataValidationAsserter::for(CircData::class) + ->assertOk([ + 'string' => 'Hello World', + 'ular' => [ + 'string' => 'Hello World', + 'circ' => [ + 'string' => 'Hello World', + ], + ], + ]) + ->assertRules([ + 'string' => ['required', 'string'], + 'ular' => ['nullable', 'array'], + 'ular.string' => ['required', 'string'], + 'ular.circ' => ['nullable', 'array'], + 'ular.circ.string' => ['required', 'string'], + 'ular.circ.ular' => ['nullable', 'array'], + ], payload: [ + 'string' => 'Hello World', + 'ular' => [ + 'string' => 'Hello World', + 'circ' => [ + 'string' => 'Hello World', + ], + ], + ]); + } + + public function testCanValidateAPropertyWithCustomRulesAsArrayContainingRegexRuleWithPipe(): void + { + $dataClass = new class extends Data { + public string $property; + + /** + * Get the validation rules. + */ + public static function rules(): array + { + return [ + 'property' => ['string', 'required', 'regex:/test|ok/'], + ]; + } + }; + + DataValidationAsserter::for($dataClass) + ->assertRules([ + 'property' => ['string', 'required', 'regex:/test|ok/'], + ]) + ->assertOk([ + 'property' => 'ok', + ]); + } + + public function testCanHandleAStringAsWrongPayload(): void + { + DataValidationAsserter::for(NestedData::class) + ->assertErrors(['hello world']) + ->assertErrors([ + 'simple' => 'hello-world', + ]); + } + + public function testCanUseHypervelDataValidationRulesInHypervelValidator(): void + { + $rules = [new Required, new StringType, new Max(10)]; + + $validatorToPass = ValidatorFacade::make( + [ + 'property' => 'test', + ], + [ + 'property' => $rules, + ], + ); + + $validatorToFail = ValidatorFacade::make( + [ + 'property' => 'testLongerText', + ], + [ + 'property' => $rules, + ], + ); + + $this->assertTrue($validatorToPass->passes()); + $this->assertFalse($validatorToFail->passes()); + } + + public function testWontValidateDefaultValuesWhenTheyAreNotProvided(): void + { + $dataClass = new class extends Data { + #[Min(10)] + public string $default = 'Hello World'; + }; + + // Absent, the default applies and only rules that skip a missing key are compiled, so declared + // presence rules still work. Supplied, the value is required, so an empty string cannot skip them. + DataValidationAsserter::for($dataClass) + ->assertOk([]) + ->assertOk(['default' => 'Hi there in this world']) + ->assertErrors(['default' => 'minimal']) + ->assertErrors(['default' => null]) + ->assertErrors(['default' => '']) + ->assertRules([ + 'default' => ['string', 'min:10'], + ], payload: []) + ->assertRules([ + 'default' => ['required', 'string', 'min:10'], + ], ['default' => 'something']); + } + + public function testWillValidateDefaultValuesWhenRulesAreOverwritten(): void + { + $dataClass = new class extends Data { + public string $default = 'Hello World'; + + /** + * Get the validation rules. + */ + public static function rules(ValidationContext $context): array + { + return [ + 'default' => ['required', 'string', 'min:10'], + ]; + } + }; + + DataValidationAsserter::for($dataClass) + ->assertErrors([]) + ->assertOk(['default' => 'Hi there in this world']) + ->assertErrors(['default' => 'minimal']) + ->assertErrors(['default' => null]) + ->assertRules([ + 'default' => ['required', 'string', 'min:10'], + ], payload: []) + ->assertRules([ + 'default' => ['required', 'string', 'min:10'], + ], ['default' => 'something']); + } + + public function testWillIgnoreDefaultValuesWhichAreOptional(): void + { + $dataClass = new class extends Data { + /** + * Create the data object. + */ + public function __construct(public string|Optional $property = new Optional) + { + } + }; + + DataValidationAsserter::for($dataClass) + ->assertOk([]) + ->assertOk(['property' => 'Hello World']) + ->assertErrors(['property' => 123]) + ->assertErrors(['property' => null]) + ->assertRules(['property' => ['sometimes', 'string']]); + } + + public function testAManualWrittenPresentAttributeRuleAlwaysOverwritesAGeneratedRequiredRule(): void + { + $dataClass = new class extends Data { + #[Present] + public array $array; + }; + + DataValidationAsserter::for($dataClass) + ->assertOk(['array' => []]) + ->assertOk(['array' => ['a', 'b']]) + ->assertErrors(['array' => null]) + ->assertRules([ + 'array' => ['array', 'present'], + ], []); + } + + public function testSupportsCustomValidationAttributes(): void + { + $dataClass = new class extends Data { + #[PassThroughCustomValidationAttribute(['url'])] + public string $url; + }; + + DataValidationAsserter::for($dataClass) + ->assertOk(['url' => 'https://spatie.be']) + ->assertErrors(['url' => 'nowp']) + ->assertRules([ + 'url' => ['required', 'string', 'url'], + ], []); + + $dataClass = new class extends Data { + #[PassThroughCustomValidationAttribute(['url', 'max:20'])] + public string $url; + }; + + DataValidationAsserter::for($dataClass) + ->assertOk(['url' => 'https://spatie.be']) + ->assertErrors(['url' => 'nowp']) + ->assertErrors(['url' => 'https://rubenvanassche.com']) + ->assertRules([ + 'url' => ['required', 'string', 'url', 'max:20'], + ], []); + + $dataClass = new class extends Data { + #[PassThroughCustomValidationAttribute([new HypervelIn(['a', 'b'])])] + public string $something; + }; + + DataValidationAsserter::for($dataClass) + ->assertOk(['something' => 'a']) + ->assertOk(['something' => 'b']) + ->assertErrors(['something' => 'c']) + ->assertRules([ + 'something' => ['required', 'string', new HypervelIn(['a', 'b'])], + ], []); + } + + public function testCanAddARequiringRuleOnAnAttributeWhichWillOverwriteTheOptionalType(): void + { + // Upstream skips this case until its rule inferrers are rewritten; Hypervel adds no + // sometimes rule beside a declared requiring rule. + $dataClass = new class extends Data { + #[Required] + #[BooleanType] + public bool $success; + + #[RequiredIf('success', 'false')] + #[StringType] + public string $error = ''; + + #[RequiredIf('success', 'true')] + #[IntegerType] + public Optional|int $id; + }; + + DataValidationAsserter::for($dataClass) + ->assertOk(['success' => true, 'id' => 1]) + ->assertErrors(['success' => true]); + } + + public function testCanValidateAnOptionalButNonexistsAttribute(): void + { + $dataClass = new class extends Data { + public array|Optional|null $property; + }; + + $this->assertSame([], $dataClass::from()->toArray()); + $this->assertSame([], $dataClass::from([])->toArray()); + $this->assertSame(['property' => null], $dataClass::from(['property' => null])->toArray()); + $this->assertSame(['property' => []], $dataClass::from(['property' => []])->toArray()); + $this->assertSame([], $dataClass::validateAndCreate([])->toArray()); + } + + #[WithConfig('data.validation_strategy', ValidationStrategy::Always->value)] + public function testIsPossibleToDefineTheValidationStrategyForEachDataObjectGloballyUsingConfig(): void + { + // The strategy is configuration read once at boot, so it is set before the application starts. + // A factory still overrides it, as the default strategy does without the configuration. + $dataClass = new class extends Data { + #[In('Hello World')] + public string $string; + }; + + $data = $dataClass::factory()->onlyValidateRequests()->from(['string' => 'Nowp']); + + $this->assertInstanceOf(Data::class, $data); + $this->assertSame('Nowp', $data->string); + + $this->expectException(ValidationException::class); + + $dataClass::from(['string' => 'Nowp']); + } + + public function testHandlesValidationWithMappedAttributes(): void + { + // Upstream skips this case because it generated rules for the mapped name; Hypervel validates the input name. + $data = TestValidationWithClassMappedAttribute::factory()->alwaysValidate()->from([ + 'some_property' => 1, + ]); + + $this->assertSame(1, $data->someProperty); + } + + public function testWillRemoveASometimesRuleGeneratedByAnOptionalTypeWhenManuallyRequiringSomething(): void + { + $dataClass = new class extends Data { + #[RequiredWith('otherProperty')] + public string|Optional $property; + + public ?string $otherProperty; + }; + + DataValidationAsserter::for($dataClass) + ->assertRules([ + 'property' => ['string', 'required_with:otherProperty'], + 'otherProperty' => ['nullable', 'string'], + ], ['property' => 'Hello World', 'otherProperty' => 'Hello World']) + ->assertOk([ + 'property' => 'Hello World', + 'otherProperty' => 'Hello World', + ]) + ->assertOk([ + 'otherProperty' => null, + ]) + ->assertErrors([ + 'otherProperty' => 'Hello World', + ]); + } + + public function testItWillMergeValidationRules(): void + { + DataValidationAsserter::for(TestDataWithMergedRuleset::class) + ->assertRules([ + 'array_rules' => ['required', 'string', 'max:10', 'min:2', 'alpha'], + 'string_rules' => ['required', 'string', 'max:10', 'min:2', 'alpha'], + 'nested' => ['required', 'array'], + 'nested.string' => ['required', 'string', 'max:10', 'min:2'], + ], []) + ->assertOk([ + 'array_rules' => 'Ruben', + 'string_rules' => 'Ruben', + 'nested' => ['string' => 'Ruben'], + ]) + // Class rules apply in declaration order, so the properties they merge are listed after the nested rules. + ->assertErrors([ + 'array_rules' => 'r', + 'string_rules' => 'r', + 'nested' => ['string' => 'r'], + ], [ + 'nested.string' => [__('validation.min.string', ['attribute' => 'nested.string', 'min' => 2])], + 'array_rules' => [__('validation.min.string', ['attribute' => 'array rules', 'min' => 2])], + 'string_rules' => [__('validation.min.string', ['attribute' => 'string rules', 'min' => 2])], + ]) + ->assertErrors([ + 'array_rules' => 'rubenvanassche', + 'string_rules' => 'rubenvanassche', + 'nested' => ['string' => 'rubenvanassche'], + ], [ + 'nested.string' => [__('validation.max.string', ['attribute' => 'nested.string', 'max' => 10])], + 'array_rules' => [__('validation.max.string', ['attribute' => 'array rules', 'max' => 10])], + 'string_rules' => [__('validation.max.string', ['attribute' => 'string rules', 'max' => 10])], + ]) + ->assertErrors([ + 'array_rules' => null, + 'string_rules' => null, + 'nested' => ['string' => null], + ], [ + 'nested.string' => [__('validation.required', ['attribute' => 'nested.string'])], + 'array_rules' => [__('validation.required', ['attribute' => 'array rules'])], + 'string_rules' => [__('validation.required', ['attribute' => 'string rules'])], + ]); + } + + // Property-morphable validation + + public function testCanValidatePropertyMorphableData(): void + { + DataValidationAsserter::for(AbstractPropertyMorphableData::class) + ->assertErrors([], [ + 'variant' => ['The variant field is required.'], + ]) + ->assertErrors([ + 'variant' => 'c', + ], [ + 'variant' => [ + 'The selected variant is invalid.', + 'The selected variant is invalid for morph.', + ], + ]) + ->assertErrors([ + 'variant' => 'a', + ], [ + 'a' => ['The a field is required.'], + 'enum' => ['The enum field is required.'], + ]) + ->assertErrors([ + 'variant' => 'a', + 'a' => 'foo', + 'enum' => 'invalid', + ], [ + 'enum' => ['The selected enum is invalid.'], + ]) + ->assertErrors([ + 'variant' => 'b', + ], [ + 'b' => ['The b field is required.'], + ]) + ->assertOk([ + 'variant' => 'a', + 'a' => 'foo', + 'enum' => 'foo', + ]) + ->assertOk([ + 'variant' => 'b', + 'b' => 'foo', + ]); + } + + public function testAllowsNullablePropertyMorphableData(): void + { + DataValidationAsserter::for(new class extends Data { + public ?AbstractPropertyMorphableData $morphable; + }) + ->assertOk([]) + ->assertOk(['morphable' => null]) + ->assertErrors(['morphable' => []], [ + 'morphable.variant' => ['The morphable.variant field is required.'], + ]); + } + + public function testCanValidateNestedPropertyMorphableData(): void + { + DataValidationAsserter::for(TestValidationNestedPropertyMorphableData::class) + ->assertErrors([ + 'nestedCollection' => [[]], + ], [ + 'nestedCollection.0.variant' => ['The nested collection.0.variant field is required.'], + ]) + ->assertErrors([ + 'nestedCollection' => [['variant' => 'c']], + ], [ + 'nestedCollection.0.variant' => [ + 'The selected nested collection.0.variant is invalid.', + 'The selected nested collection.0.variant is invalid for morph.', + ], + ]) + ->assertErrors([ + 'nestedCollection' => [['variant' => 'a'], ['variant' => 'b']], + ], [ + 'nestedCollection.0.a' => ['The nested collection.0.a field is required.'], + 'nestedCollection.0.enum' => ['The nested collection.0.enum field is required.'], + 'nestedCollection.1.b' => ['The nested collection.1.b field is required.'], + ]) + ->assertErrors([ + 'nestedCollection' => [['variant' => 'a', 'a' => 'foo', 'enum' => 'invalid']], + ], [ + 'nestedCollection.0.enum' => ['The selected nested collection.0.enum is invalid.'], + ]) + ->assertOk([ + 'nestedCollection' => [['variant' => 'a', 'a' => 'foo', 'enum' => 'foo'], ['variant' => 'b', 'b' => 'bar']], + ]); + } + + public function testCanValidatePropertyMorphableDataWithCustomMessagesAndAttributes(): void + { + DataValidationAsserter::for(TestValidationCustomMessagePropertyMorphableDataA::class) + ->assertErrors([ + 'variant' => 'a', + 'abstract_integer' => 2, + 'concrete_integer' => 2, + ], [ + 'concrete_integer' => ['Concrete class integer test message.'], + 'concrete_string' => ['The [Concrete String] field is required.'], + 'abstract_integer' => ['Abstract class integer test message.'], + 'abstract_string' => ['The [Abstract String] field is required.'], + ]); + } + + public function testCanValidateCollectionsOfPropertyMorphableDataWithCustomMessagesAndAttributes(): void + { + DataValidationAsserter::for(TestCustomMessagesInCollectionOfPropertyMorphableData::class) + ->assertErrors([ + 'items' => [ + [ + 'variant' => 'a', + 'abstract_integer' => 2, + 'concrete_integer' => 2, + ], + [ + 'variant' => 'invalid', + 'abstract_integer' => 2, + 'concrete_integer' => 2, + ], + [ + 'variant' => 'b', + 'abstract_integer' => 2, + 'concrete_integer' => 2, + ], + ]], [ + 'items.0.concrete_integer' => ['Concrete class A integer test message.'], + 'items.0.concrete_string' => ['The [Concrete String A] field is required.'], + 'items.0.abstract_integer' => ['Abstract class integer test message.'], + 'items.0.abstract_string' => ['The [Abstract String] field is required.'], + + 'items.1.variant' => [ + 'The selected items.1.variant is invalid.', + 'The selected items.1.variant is invalid for morph.', + ], + 'items.1.abstract_integer' => ['Abstract class integer test message.'], + 'items.1.abstract_string' => ['The [Abstract String] field is required.'], + + 'items.2.concrete_integer' => ['Concrete class B integer test message.'], + 'items.2.concrete_string' => ['The [Concrete String B] field is required.'], + 'items.2.abstract_integer' => ['Concrete Class B override of Abstract class integer test message.'], + 'items.2.abstract_string' => ['The [Abstract String] field is required.'], + ]); + } + + public function testCanValidatePropertyMorphableDataWithADefault(): void + { + DataValidationAsserter::for(TestValidationAbstractPropertyMorphableDefaultData::class) + ->assertErrors([], [ + 'a' => ['The a field is required.'], + 'enum' => ['The enum field is required.'], + ]) + ->assertOk([ + 'a' => 'foo', + 'enum' => 'foo', + ]) + ->assertOk([ + 'variant' => 'a', + 'a' => 'foo', + 'enum' => 'foo', + ]); + } + + public function testCanValidatePropertyMorphableDataInsideAnArrayOfNonMorphableData(): void + { + DataValidationAsserter::for(TestValidationNestedMorphableCollectionData::class) + ->assertOk([ + 'items' => [ + ['morphable' => ['type' => 'a', 'a_string' => 'foo', 'a_other_string' => 'bar']], + ['morphable' => null], + ['morphable' => ['type' => 'b', 'b_float' => 1500.5]], + ], + ]) + ->assertErrors([ + 'items' => [ + ['morphable' => ['type' => 'a']], + ['morphable' => ['type' => 'b']], + ['morphable' => ['type' => 'invalid']], + ], + ], [ + 'items.0.morphable.a_string' => ['Data A string required test.'], + 'items.0.morphable.a_other_string' => ['The [A Other String] field is required.'], + 'items.1.morphable.b_float' => ['The items.1.morphable.b float field is required.'], + 'items.2.morphable.type' => ['The selected items.2.morphable.type is invalid for morph.'], + ]); + } +} + +class TestValidationNestedPropertyMorphableData extends Data +{ + /** + * Create the data object. + */ + public function __construct( + /** @var AbstractPropertyMorphableData[] */ + public ?DataCollection $nestedCollection, + ) { + } +} + +abstract class TestValidationCustomMessageAbstractPropertyMorphableData extends Data implements PropertyMorphableData +{ + /** + * Create the morphable data object. + */ + public function __construct( + #[PropertyForMorph] + public PropertyMorphableEnum $variant, + #[Max(1)] + public int $abstract_integer, + public string $abstract_string, + ) { + } + + /** + * Resolve the concrete class for the variant. + */ + public static function morph(array $properties): ?string + { + return match ($properties['variant']) { + PropertyMorphableEnum::A => TestValidationCustomMessagePropertyMorphableDataA::class, + default => null, + }; + } + + /** + * Get the validation messages. + */ + public static function messages(): array + { + return [ + 'abstract_integer.max' => 'Abstract class integer test message.', + ]; + } + + /** + * Get the validation attribute names. + */ + public static function attributes(): array + { + return [ + 'abstract_string' => '[Abstract String]', + ]; + } +} + +class TestValidationCustomMessagePropertyMorphableDataA extends TestValidationCustomMessageAbstractPropertyMorphableData +{ + /** + * Create the concrete data object. + */ + public function __construct( + int $abstract_integer, + string $abstract_string, + #[Max(1)] + public int $concrete_integer, + public string $concrete_string, + ) { + parent::__construct(PropertyMorphableEnum::A, $abstract_integer, $abstract_string); + } + + /** + * Get the validation messages. + */ + public static function messages(): array + { + return [ + ...parent::messages(), + 'concrete_integer.max' => 'Concrete class integer test message.', + ]; + } + + /** + * Get the validation attribute names. + */ + public static function attributes(): array + { + return [ + ...parent::attributes(), + 'concrete_string' => '[Concrete String]', + ]; + } +} + +class TestCustomMessagesInCollectionOfPropertyMorphableData extends Data +{ + /** + * Create the data object. + */ + public function __construct( + /** + * @var array + */ + public array $items, + ) { + } +} + +abstract class TestCustomMessageOfPropertyMorphableData extends Data implements PropertyMorphableData +{ + /** + * Create the morphable data object. + */ + public function __construct( + #[PropertyForMorph] + public PropertyMorphableEnum $variant, + #[Max(1)] + public int $abstract_integer, + public string $abstract_string, + ) { + } + + /** + * Resolve the concrete class for the variant. + */ + public static function morph(array $properties): ?string + { + return match ($properties['variant']) { + PropertyMorphableEnum::A => TestCustomMessagePropertyMorphableDataA::class, + PropertyMorphableEnum::B => TestCustomMessagePropertyMorphableDataB::class, + default => null, + }; + } + + /** + * Get the validation messages. + */ + public static function messages(): array + { + return [ + 'abstract_integer.max' => 'Abstract class integer test message.', + ]; + } + + /** + * Get the validation attribute names. + */ + public static function attributes(): array + { + return [ + 'abstract_string' => '[Abstract String]', + ]; + } +} + +class TestCustomMessagePropertyMorphableDataA extends TestCustomMessageOfPropertyMorphableData +{ + /** + * Create the concrete data object. + */ + public function __construct( + int $abstract_integer, + string $abstract_string, + #[Max(1)] + public int $concrete_integer, + public string $concrete_string, + ) { + parent::__construct(PropertyMorphableEnum::A, $abstract_integer, $abstract_string); + } + + /** + * Get the validation messages. + */ + public static function messages(): array + { + return [ + ...parent::messages(), + 'concrete_integer.max' => 'Concrete class A integer test message.', + ]; + } + + /** + * Get the validation attribute names. + */ + public static function attributes(): array + { + return [ + ...parent::attributes(), + 'concrete_string' => '[Concrete String A]', + ]; + } +} + +class TestCustomMessagePropertyMorphableDataB extends TestCustomMessageOfPropertyMorphableData +{ + /** + * Create the concrete data object. + */ + public function __construct( + int $abstract_integer, + string $abstract_string, + #[Max(1)] + public int $concrete_integer, + public string $concrete_string, + ) { + parent::__construct(PropertyMorphableEnum::B, $abstract_integer, $abstract_string); + } + + /** + * Get the validation messages. + */ + public static function messages(): array + { + return [ + ...parent::messages(), + 'abstract_integer.max' => 'Concrete Class B override of Abstract class integer test message.', + 'concrete_integer.max' => 'Concrete class B integer test message.', + ]; + } + + /** + * Get the validation attribute names. + */ + public static function attributes(): array + { + return [ + ...parent::attributes(), + 'concrete_string' => '[Concrete String B]', + ]; + } +} + +abstract class TestValidationAbstractPropertyMorphableDefaultData extends Data implements PropertyMorphableData +{ + #[PropertyForMorph] + public PropertyMorphableEnum $variant = PropertyMorphableEnum::A; + + /** + * Resolve the concrete class for the variant. + */ + public static function morph(array $properties): ?string + { + return match ($properties['variant'] ?? null) { + PropertyMorphableEnum::A => TestValidationPropertyMorphableDefaultDataA::class, + default => null, + }; + } +} + +class TestValidationPropertyMorphableDefaultDataA extends TestValidationAbstractPropertyMorphableDefaultData +{ + /** + * Create the concrete data object. + */ + public function __construct(public string $a, public DummyBackedEnum $enum) + { + $this->variant = PropertyMorphableEnum::A; + } +} + +abstract class TestValidationNestedMorphableAbstractData extends Data implements PropertyMorphableData +{ + /** + * Create the morphable data object. + */ + public function __construct( + #[PropertyForMorph] + public string $type, + ) { + } + + /** + * Resolve the concrete class for the type. + */ + public static function morph(array $properties): ?string + { + return match ($properties['type']) { + 'a' => TestValidationNestedMorphableDataA::class, + 'b' => TestValidationNestedMorphableDataB::class, + default => null, + }; + } +} + +class TestValidationNestedMorphableDataA extends TestValidationNestedMorphableAbstractData +{ + /** + * Create the concrete data object. + */ + public function __construct( + public string $a_string, + public string $a_other_string, + ) { + parent::__construct('a'); + } + + /** + * Get the validation messages. + */ + public static function messages(): array + { + return [ + 'a_string.required' => 'Data A string required test.', + ]; + } + + /** + * Get the validation attribute names. + */ + public static function attributes(): array + { + return [ + 'a_other_string' => '[A Other String]', + ]; + } +} + +class TestValidationNestedMorphableDataB extends TestValidationNestedMorphableAbstractData +{ + /** + * Create the concrete data object. + */ + public function __construct( + public float $b_float, + ) { + parent::__construct('b'); + } +} + +class TestValidationNestedMorphableWrapperData extends Data +{ + /** + * Create the wrapper data object. + */ + public function __construct( + public ?TestValidationNestedMorphableAbstractData $morphable, + ) { + } +} + +class TestValidationNestedMorphableCollectionData extends Data +{ + /** + * Create the collection data object. + */ + public function __construct( + /** @var TestValidationNestedMorphableWrapperData[] */ + public array $items, + ) { + } +} + +class TestValidationDataWithCollectionNestedDataWithFieldReference extends Data +{ + public DataWithReferenceFieldValidationAttribute $nested; +} + +class TestDataWithRootReferenceFieldValidationAttribute extends Data +{ + #[RequiredIf(new FieldReference('check_string', true), true)] + public string $string; +} + +class CollectionClassA extends Data +{ + public SimpleData $nested; +} + +class CollectionClassTable extends Data +{ + public ?SimpleData $nested; +} + +class CollectionClassC extends Data +{ + public Optional|SimpleData $nested; +} + +class CollectionClassK extends Data +{ + public DummyDataWithContextOverwrittenValidationRules $nested; +} + +class ValidationTestDeepNestedDataWithContextOverwrittenRules extends Data +{ + public string $deep_string; + + #[Required] + public bool $deep_validate_as_email; + + /** + * Require an email when the item asks for it. + */ + public static function rules(ValidationContext $context): array + { + return $context->payload['deep_validate_as_email'] ?? false + ? ['deep_string' => ['required', 'string', 'email']] + : []; + } +} + +class ValidationTestNestedDataWithContextOverwrittenRules extends Data +{ + public string $string; + + #[Required] + public bool $validate_as_email; + + #[DataCollectionOf(ValidationTestDeepNestedDataWithContextOverwrittenRules::class), Required] + public DataCollection $items; + + /** + * Require an email when the item asks for it. + */ + public static function rules(ValidationContext $context): array + { + return $context->payload['validate_as_email'] ?? false + ? ['string' => ['required', 'string', 'email']] + : []; + } +} + +class NestedClassJ extends Data +{ + public string $property; + + /** + * Check the context passed to the nested data rules. + */ + public static function rules(ValidationContext $context): array + { + $correct = $context->payload['property'] === 'J' + && $context->fullPayload['property'] === 'Root' + && $context->path->equals('nested.data'); + + if (! $correct) { + throw new Exception('Should not end up here'); + } + + return []; + } +} + +class NestedClassK extends Data +{ + public string $property; + + /** + * Check the context passed to the rules of data nested in a collection item. + */ + public static function rules(ValidationContext $context): array + { + $correct = $context->payload['property'] === 'K' + && $context->fullPayload['property'] === 'Root' + && $context->path->equals('nested.collection.0.nested'); + + if (! $correct) { + throw new Exception('Should not end up here'); + } + + return []; + } +} + +class NestedClassL extends Data +{ + public string $property; + + /** + * Check the context passed to the rules of a nested collection item. + */ + public static function rules(ValidationContext $context): array + { + $correct = $context->payload['property'] === 'L' + && $context->fullPayload['property'] === 'Root' + && $context->path->equals('nested.collection.0.collection.0'); + + if (! $correct) { + throw new Exception('Should not end up here'); + } + + return []; + } +} + +class NestedClassM extends Data +{ + public string $property; + + public NestedClassK $nested; + + #[DataCollectionOf(NestedClassL::class)] + public DataCollection $collection; + + /** + * Check the context passed to the collection item rules. + */ + public static function rules(ValidationContext $context): array + { + $correct = $context->payload['property'] === 'M' + && $context->fullPayload['property'] === 'Root' + && $context->path->equals('nested.collection.0'); + + if (! $correct) { + throw new Exception('Should not end up here'); + } + + return []; + } +} + +class NestedClassN extends Data +{ + public string $property; + + public NestedClassJ $data; + + #[DataCollectionOf(NestedClassM::class)] + public DataCollection $collection; + + /** + * Check the context passed to the nested rules. + */ + public static function rules(ValidationContext $context): array + { + $correct = $context->payload['property'] === 'N' + && $context->fullPayload['property'] === 'Root' + && $context->path->equals('nested'); + + if (! $correct) { + throw new Exception('Should not end up here'); + } + + return []; + } +} + +class TestNestedValidationMessagesDataA extends Data +{ + public string $name; + + public string $song; + + /** + * Get the validation messages. + */ + public static function messages(): array + { + return [ + 'name.required' => 'Fix it Rick!', + ]; + } +} + +class TestNestedValidationMessagesDataB extends Data +{ + public string $name; + + public string $song; + + /** + * Get the validation messages. + */ + public static function messages(): array + { + return [ + 'name' => ['required' => 'Fix it Rick!'], + ]; + } +} + +class TestNestedValidationMessagesDataC extends Data +{ + public string $name; + + public string $song; + + /** + * Get the validation messages. + */ + public static function messages(): array + { + return [ + 'required' => 'Fix it Rick!', + ]; + } +} + +class TestCollectionValidationMessagesDataA extends Data +{ + public string $name; + + public string $song; + + /** + * Get the validation messages. + */ + public static function messages(): array + { + return [ + 'name.required' => 'Fix it Rick!', + ]; + } +} + +class TestCollectionValidationMessagesDataB extends Data +{ + public string $name; + + public string $song; + + /** + * Get the validation messages. + */ + public static function messages(): array + { + return [ + 'name' => ['required' => 'Fix it Rick!'], + ]; + } +} + +class TestCollectionValidationMessagesDataC extends Data +{ + public string $name; + + public string $song; + + /** + * Get the validation messages. + */ + public static function messages(): array + { + return [ + 'required' => 'Fix it Rick!', + ]; + } +} + +class TestDoubleNestedCollectionValidationMessagesDataA extends Data +{ + public string $name; + + public string $song; + + /** + * Get the validation messages. + */ + public static function messages(): array + { + return [ + 'name.required' => 'Fix it Rick!', + ]; + } +} + +class TestDoubleNestedCollectionValidationMessagesInitialDataA extends Data +{ + #[DataCollectionOf(TestDoubleNestedCollectionValidationMessagesDataA::class)] + public DataCollection $nestedCollection; +} + +class TestNestedValidationAttributesData extends Data +{ + public string $name; + + /** + * Get the validation attribute names. + */ + public static function attributes(): array + { + return [ + 'name' => 'rickster', + ]; + } +} + +#[MergeValidationRules] +class TestNestedDataWithMergedRules extends SimpleData +{ + /** + * Get the validation rules merged with the inferred rules. + */ + public static function rules(ValidationContext $context): array + { + return [ + 'string' => ['max:10', 'min:2'], + ]; + } +} + +#[MergeValidationRules] +class TestDataWithMergedRuleset extends Data +{ + /** + * Create the data object. + */ + public function __construct( + #[Max(10)] + public string $array_rules, + #[Max(10)] + public string $string_rules, + #[WithoutValidation] + public string $without_validation, + public TestNestedDataWithMergedRules $nested + ) { + } + + /** + * Get the validation rules merged with the inferred rules. + */ + public static function rules(): array + { + return [ + 'array_rules' => ['min:2', 'alpha'], + 'string_rules' => 'min:2|alpha', + ]; + } +} + +#[MapInputName(SnakeCaseMapper::class)] +class TestValidationWithClassMappedAttribute extends Data +{ + /** + * Create the mapped data object. + */ + public function __construct( + #[Required] + public readonly int $someProperty, + ) { + } +} + +class TestCollectedValidationAttributesData extends Data +{ + public string $name; + + /** + * Get the validation attribute names. + */ + public static function attributes(): array + { + return [ + 'name' => 'rickster', + ]; + } +} diff --git a/tests/Data/WithDataTest.php b/tests/Data/WithDataTest.php index f7f5c6bdc2..638d0ca9a4 100644 --- a/tests/Data/WithDataTest.php +++ b/tests/Data/WithDataTest.php @@ -13,6 +13,7 @@ use Hypervel\Database\Eloquent\Model; use Hypervel\Foundation\Http\FormRequest; use Hypervel\Testbench\TestCase; +use Hypervel\Tests\Data\Fixtures\SimpleData; use Hypervel\Validation\ValidationException; class WithDataTest extends TestCase @@ -25,7 +26,7 @@ protected function getPackageProviders(Application $app): array return [DataServiceProvider::class]; } - public function testModelCanDeclareItsDataClassWithAProperty(): void + public function testCanAddTheWithDataTraitToAModel(): void { $model = new WithDataModelSource; $model->setRawAttributes(['name' => 'Taylor']); @@ -36,7 +37,7 @@ public function testModelCanDeclareItsDataClassWithAProperty(): void $this->assertSame('Taylor', $data->name); } - public function testArrayableCanDeclareItsDataClassWithAMethod(): void + public function testCanDefineTheWithDataTraitDataClassByMethod(): void { $data = (new WithDataArrayableSource('Taylor'))->getData(); @@ -44,6 +45,22 @@ public function testArrayableCanDeclareItsDataClassWithAMethod(): void $this->assertSame('Taylor', $data->name); } + public function testCanAddTheWithDataTraitToARequest(): void + { + $formRequest = new class extends FormRequest { + /** @use WithData */ + use WithData; + + public string $dataClass = SimpleData::class; + }; + + $formRequest->replace([ + 'string' => 'Hello World', + ]); + + $this->assertEquals(SimpleData::from('Hello World'), $formRequest->getData()); + } + public function testPropertyDeclarationTakesPrecedenceOverMethodDeclaration(): void { $data = (new WithDataPrecedenceSource)->getData(); diff --git a/tests/Data/WrapTest.php b/tests/Data/WrapTest.php new file mode 100644 index 0000000000..39f461d00f --- /dev/null +++ b/tests/Data/WrapTest.php @@ -0,0 +1,331 @@ +assertSame( + ['wrap' => ['string' => 'Hello World']], + SimpleData::from('Hello World') + ->wrap('wrap') + ->toResponse(Request::create('/')) + ->getData(true), + ); + + $this->assertSame([ + 'wrap' => [ + ['string' => 'Hello'], + ['string' => 'World'], + ], + ], SimpleData::collect(['Hello', 'World'], DataCollection::class) + ->wrap('wrap') + ->toResponse(Request::create('/')) + ->getData(true)); + } + + #[WithConfig('data.wrap', 'wrap')] + public function testCanWrapDataObjectsUsingAGlobalDefault(): void + { + $this->assertSame( + ['wrap' => ['string' => 'Hello World']], + SimpleData::from('Hello World')->toResponse(Request::create('/'))->getData(true), + ); + + $this->assertSame( + ['other-wrap' => ['string' => 'Hello World']], + SimpleData::from('Hello World')->wrap('other-wrap')->toResponse(Request::create('/'))->getData(true), + ); + + $this->assertSame( + ['string' => 'Hello World'], + SimpleData::from('Hello World')->withoutWrapping()->toResponse(Request::create('/'))->getData(true), + ); + + $this->assertSame([ + 'wrap' => [ + ['string' => 'Hello'], + ['string' => 'World'], + ], + ], SimpleData::collect(['Hello', 'World'], DataCollection::class)->toResponse(Request::create('/'))->getData(true)); + + $this->assertSame( + ['string' => 'Hello World'], + SimpleData::from('Hello World')->withoutWrapping()->toResponse(Request::create('/'))->getData(true), + ); + + $this->assertSame([ + 'other-wrap' => [ + ['string' => 'Hello'], + ['string' => 'World'], + ], + ], (new DataCollection(SimpleData::class, ['Hello', 'World'])) + ->wrap('other-wrap') + ->toResponse(Request::create('/')) + ->getData(true)); + + $this->assertSame([ + ['string' => 'Hello'], + ['string' => 'World'], + ], (new DataCollection(SimpleData::class, ['Hello', 'World'])) + ->withoutWrapping() + ->toResponse(Request::create('/')) + ->getData(true)); + } + + public function testCanSetADefaultWrapOnADataObject(): void + { + $this->assertSame( + ['wrap' => ['string' => 'Hello World']], + SimpleDataWithWrap::from('Hello World')->toResponse(Request::create('/'))->getData(true), + ); + + $this->assertSame( + ['other-wrap' => ['string' => 'Hello World']], + SimpleDataWithWrap::from('Hello World')->wrap('other-wrap')->toResponse(Request::create('/'))->getData(true), + ); + + $this->assertSame( + ['string' => 'Hello World'], + SimpleDataWithWrap::from('Hello World')->withoutWrapping()->toResponse(Request::create('/'))->getData(true), + ); + } + + public function testWrapsAdditionalData(): void + { + $dataClass = new class('Hello World') extends Data { + /** + * Create a data object with a string. + */ + public function __construct( + public string $string + ) { + } + + /** + * Get the data appended to the output. + */ + public function with(): array + { + return ['with' => 'this']; + } + }; + + $data = $dataClass->additional(['additional' => 'this']) + ->wrap('wrap') + ->toResponse(Request::create('/')) + ->getData(true); + + $this->assertSame([ + 'wrap' => ['string' => 'Hello World'], + 'with' => 'this', + 'additional' => 'this', + ], $data); + } + + public function testWrapsComplexDataStructures(): void + { + $data = new MultiNestedData( + new NestedData(SimpleData::from('Hello')), + [ + new NestedData(SimpleData::from('World')), + ], + ); + + $this->assertSame([ + 'wrap' => [ + 'nested' => ['simple' => ['string' => 'Hello']], + 'nestedCollection' => [ + ['simple' => ['string' => 'World']], + ], + ], + ], $data->wrap('wrap')->toResponse(Request::create('/'))->getData(true)); + } + + #[WithConfig('data.wrap', 'wrap')] + public function testWrapsComplexDataStructuresWithAGlobal(): void + { + $data = new MultiNestedData( + new NestedData(SimpleData::from('Hello')), + [ + new NestedData(SimpleData::from('World')), + ], + ); + + $this->assertSame([ + 'wrap' => [ + 'nested' => ['simple' => ['string' => 'Hello']], + 'nestedCollection' => [ + 'wrap' => [ + ['simple' => ['string' => 'World']], + ], + ], + ], + ], $data->wrap('wrap')->toResponse(Request::create('/'))->getData(true)); + } + + public function testOnlyWrapsResponsesDefaultTransformationsWillNotWrap(): void + { + $this->assertSame(['string' => 'Hello World'], SimpleData::from('Hello World')->wrap('wrap')->toArray()); + + $this->assertSame([ + ['string' => 'Hello'], + ['string' => 'World'], + ], SimpleData::collect(['Hello', 'World'], DataCollection::class)->wrap('wrap')->toArray()); + } + + public function testWillWrapResponsesWhichAreData(): void + { + Route::post('/example-route', function (): SimpleData { + return SimpleData::from(request()->input('string'))->wrap('data'); + }); + + // Responses use 200 for every method; Spatie returns 201 for every POST (README). + $this->performRequest('Hello World') + ->assertOk() + ->assertJson(['data' => ['string' => 'Hello World']]); + } + + public function testWillWrapResponsesWhichAreDataCollections(): void + { + Route::post('/example-route', function (): DataCollection { + return SimpleData::collect([ + request()->input('string'), + strtoupper(request()->input('string')), + ], DataCollection::class)->wrap('data'); + }); + + $this->performRequest('Hello World') + ->assertOk() + ->assertJson([ + 'data' => [ + ['string' => 'Hello World'], + ['string' => 'HELLO WORLD'], + ], + ]); + } + + public function testCheckHypervelFunctionality(): void + { + Route::post('/resource', function (): TestResource { + return TestResource::make([]); + }); + + Route::post('/collection', function (): TestResourceCollection { + return new TestResourceCollection([ + [], + [], + ]); + }); + + $this->withoutExceptionHandling(); + + $expectedResource = [ + 'id' => 1, + 'nested' => [ + 'id' => 2, + ], + 'nested_collection' => [ + ['id' => 3], + ['id' => 4], + ], + 'nested_collection_object' => [ + ['id' => 5], + ['id' => 6], + ], + ]; + + $this->post('/resource') + ->assertExactJson(['data' => $expectedResource]); + + $this->post('/collection') + ->assertExactJson([ + 'data' => [ + $expectedResource, + $expectedResource, + ], + ]); + } + + /** + * Post a string to the example route. + */ + protected function performRequest(string $string): TestResponse + { + return $this->postJson('/example-route', [ + 'string' => $string, + ]); + } +} + +class TestEmbeddedResource extends JsonResource +{ + /** + * Transform the resource into an array. + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this['id'], + ]; + } +} + +class TestEmbeddedResourceCollection extends ResourceCollection +{ + public ?string $collects = TestEmbeddedResource::class; +} + +class TestResource extends JsonResource +{ + /** + * Transform the resource into an array. + */ + public function toArray(Request $request): array + { + return [ + 'id' => 1, + 'nested' => TestEmbeddedResource::make(['id' => 2]), + 'nested_collection' => TestEmbeddedResource::collection([ + ['id' => 3], + ['id' => 4], + ]), + 'nested_collection_object' => new TestEmbeddedResourceCollection([ + ['id' => 5], + ['id' => 6], + ]), + ]; + } +} + +class TestResourceCollection extends ResourceCollection +{ + public ?string $collects = TestResource::class; +} diff --git a/tests/Routing/RouteDependencyResolverTest.php b/tests/Routing/RouteDependencyResolverTest.php index 697e4d01f0..d9f94ce601 100644 --- a/tests/Routing/RouteDependencyResolverTest.php +++ b/tests/Routing/RouteDependencyResolverTest.php @@ -8,7 +8,10 @@ use Hypervel\Database\Eloquent\Model; use Hypervel\Http\Request; use Hypervel\Routing\CallableDispatcher; +use Hypervel\Routing\Controller; +use Hypervel\Routing\ControllerDispatcher; use Hypervel\Routing\Route; +use TypeError; class RouteDependencyResolverTest extends RoutingTestCase { @@ -29,6 +32,47 @@ public function testInjectedModelsAreFreshWhileOrdinaryServicesRemainShared(): v $this->assertSame([false, 1], $dispatcher->dispatch($route, $action)); $this->assertSame([false, 2], $dispatcher->dispatch($route, $action)); } + + public function testTypedRouteParametersConvertWithPhpWeakTyping(): void + { + $container = new Container; + $callables = new CallableDispatcher($container); + $controllers = new ControllerDispatcher($container); + $closure = static fn (int $id): int => $id; + $route = (new Route('GET', '/posts/{id}', $closure))->bind(Request::create('/posts/123')); + + $this->assertSame(123, $callables->dispatch($route, $closure)); + $this->assertSame(123, $controllers->dispatch($route, new TypedRouteController, 'show')); + $this->assertSame(123, $controllers->dispatch($route, new TypedRouteBaseController, 'show')); + + $invalid = (new Route('GET', '/posts/{id}', $closure))->bind(Request::create('/posts/abc')); + + $this->expectException(TypeError::class); + + $callables->dispatch($invalid, $closure); + } +} + +class TypedRouteController +{ + /** + * Return the route identifier. + */ + public function show(int $id): int + { + return $id; + } +} + +class TypedRouteBaseController extends Controller +{ + /** + * Return the route identifier. + */ + public function show(int $id): int + { + return $id; + } } class InjectedRouteModel extends Model