From caeff93798a40bf604f19411601877628f54eaf0 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 3 Oct 2026 19:59:44 +0000 Subject: [PATCH 01/14] Reconcile Laravel Data with upstream main and fix validation input Complete the owner-assigned Laravel Data assessment against spatie/laravel-data main (ce296f2), restoring the upstream APIs Hypervel had dropped and fixing defects found along the way. Restored upstream APIs: - RouteParameterReference, AuthenticatedUserReference and ContainerReference for validation attributes. An unresolvable container binding now throws instead of resolving to null, which upstream lets turn a database constraint into whereNull. - The computed-input ignore option, non-throwing max transformation depth (Eloquent casts still throw), make:data suffix and namespace config (--target-namespace selects one class's namespace), the VarDumper caster mode, and invalid nested partial checks with ignore_invalid_partials. - The upstream custom cast signature, with a view of the declared property values; prepareForPipeline(); configurable rule_inferrers, resolved for each compilation so scoped inferrers follow the current coroutine; and later-payload precedence for multiple payloads. Fixes: - Validation receives the complete normalized input. Confirmed always failed, and rules such as required_if silently passed, because undeclared fields never reached the validator. Class rule and inferrer contexts and beforeValidation hooks now see the same input; validated output still excludes undeclared keys. - Custom data collection withResponse() overrides are called. - PropertyRules::prepend() keeps argument order (upstream Arr::prepend bug). - A prepareData hook receives each selected value under the spelling resolution reads first, so an identity hook changes nothing. - Inferrer-removed keyword rules never resolve their parameters, and surviving rules resolve once. Performance: the general creation path reads each property once, and nested partial checks only run where a selection continues into a property. The owner accepted the remaining measured cost of the restored partial check (about 0.57 us per item for a collection with a nested only()). The README differences are reconciled against main, with the Data docs, porting guide, sync note and benchmark scenarios updated. Validation: tests/Data, PHPStan, harness comparisons against the base revision, and the SDK generator suite against this branch. --- docs/upstream-sync/sync.yaml | 1 + src/data/README.md | 18 +- src/data/config/data.php | 66 +- src/data/src/Casts/BuiltinTypeCast.php | 5 +- src/data/src/Casts/Cast.php | 5 +- src/data/src/Casts/CastableCast.php | 5 +- src/data/src/Casts/DateTimeInterfaceCast.php | 5 +- src/data/src/Casts/EnumCast.php | 5 +- src/data/src/Casts/IterableItemCast.php | 5 +- src/data/src/Concerns/BaseData.php | 13 +- src/data/src/Console/DataMakeCommand.php | 34 +- src/data/src/Contracts/BaseData.php | 8 + src/data/src/Data.php | 2 + src/data/src/DataServiceProvider.php | 19 +- .../CannotPerformPartialOnDataField.php | 2 +- .../CannotResolveRouteParameterReference.php | 28 + .../Http/Resources/DataCollectionResource.php | 4 +- src/data/src/RuleInferrers/RuleInferrer.php | 18 + .../Support/Creation/ConstructionState.php | 57 ++ src/data/src/Support/Creation/DataCreator.php | 348 ++++++++++- src/data/src/Support/DataConfig.php | 35 ++ .../Support/Factories/DataClassFactory.php | 2 + .../Transformation/DataTransformer.php | 51 +- .../Support/Transformation/PartialTree.php | 30 +- .../Transformation/TransformationContext.php | 4 + .../TransformationContextFactory.php | 10 +- .../Validation/DataValidationCompiler.php | 154 ++++- .../src/Support/Validation/PropertyRules.php | 109 ++++ .../References/AuthenticatedUserReference.php | 37 ++ .../References/ContainerReference.php | 37 ++ .../References/RouteParameterReference.php | 50 ++ src/docs/data-objects.md | 164 ++++- src/docs/porting-from-laravel.md | 13 +- tests/Benchmarks/Data/benchmark.php | 64 +- tests/Data/Attributes/AttributeTest.php | 3 +- tests/Data/Casts/BuiltinTypeCastTest.php | 6 +- .../Data/Casts/DateTimeInterfaceCastTest.php | 39 +- tests/Data/Casts/EnumCastTest.php | 31 +- tests/Data/Console/DataMakeCommandTest.php | 58 +- tests/Data/DataServiceProviderTest.php | 2 +- tests/Data/Http/ResourceResponseTest.php | 21 + .../Data/Support/Creation/DataCreatorTest.php | 531 +++++++++++++++- tests/Data/Support/DataClassTest.php | 15 +- tests/Data/Support/DataPropertyTest.php | 3 +- .../Transformation/DataTransformerTest.php | 113 ++++ .../Transformation/PartialTreeTest.php | 7 + .../TransformationContextFactoryTest.php | 14 +- .../Support/Validation/DataValidatorTest.php | 565 ++++++++++++++++++ .../Support/Validation/PropertyRulesTest.php | 71 +++ .../AuthenticatedUserReferenceTest.php | 111 ++++ .../References/ContainerReferenceTest.php | 59 ++ .../RouteParameterReferenceTest.php | 169 ++++++ .../VarDumper/DataVarDumperCasterTest.php | 59 +- 53 files changed, 3126 insertions(+), 159 deletions(-) create mode 100644 src/data/src/Exceptions/CannotResolveRouteParameterReference.php create mode 100644 src/data/src/RuleInferrers/RuleInferrer.php create mode 100644 src/data/src/Support/Validation/PropertyRules.php create mode 100644 src/data/src/Support/Validation/References/AuthenticatedUserReference.php create mode 100644 src/data/src/Support/Validation/References/ContainerReference.php create mode 100644 src/data/src/Support/Validation/References/RouteParameterReference.php create mode 100644 tests/Data/Support/Validation/PropertyRulesTest.php create mode 100644 tests/Data/Support/Validation/References/AuthenticatedUserReferenceTest.php create mode 100644 tests/Data/Support/Validation/References/ContainerReferenceTest.php create mode 100644 tests/Data/Support/Validation/References/RouteParameterReferenceTest.php diff --git a/docs/upstream-sync/sync.yaml b/docs/upstream-sync/sync.yaml index 516e1e4f2e..f5565e69be 100644 --- a/docs/upstream-sync/sync.yaml +++ b/docs/upstream-sync/sync.yaml @@ -156,6 +156,7 @@ spatie/laravel-data: checked_through: null last_reviewed_pr: null sync_date: null + 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. spatie/laravel-permission: branch: main diff --git a/src/data/README.md b/src/data/README.md index 1b88fc97a4..bdce65ba49 100644 --- a/src/data/README.md +++ b/src/data/README.md @@ -4,13 +4,25 @@ 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; 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. Later payloads take precedence, and a later explicit `null` replaces an earlier value where Spatie keeps the earlier value. An `Optional` value never replaces an earlier value. + +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 built-in normalizer and rule inferrer classes are not included. Typed iterables are always cast and transformed, as with Spatie's `cast_and_transform_iterables` feature, which is not a configuration option. + +Responses use the `200` status code for every request method, while Spatie uses `201` for `POST` requests and provides a `calculateResponseStatus()` override. Set a different status in `withResponse()`. Hypervel rejects data classes with conflicting input or output mappings when the class is first used. Uniform nested collections use wildcard validation rules, while collections with different item shapes or rules use exact indexed rules. -Constructor injection uses Hypervel contextual attributes, including property extraction through `CurrentUser` and `RouteParameter`. Their resolved value always wins over payload input, including `null`; use a named factory or creation hook when payload values should take precedence. +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`. 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. 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()`. + +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 [casts and transformers](https://hypervel.org/docs/data-objects#casts-and-transformers). + +`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. diff --git a/src/data/config/data.php b/src/data/config/data.php index fb55f2d751..f63e5d8b49 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,6 +40,12 @@ */ '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. @@ -44,10 +59,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 +84,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 +98,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/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/Concerns/BaseData.php b/src/data/src/Concerns/BaseData.php index 1922fa4fa9..9b9cd1137a 100644 --- a/src/data/src/Concerns/BaseData.php +++ b/src/data/src/Concerns/BaseData.php @@ -160,7 +160,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/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..892fc0cc8c 100644 --- a/src/data/src/Contracts/BaseData.php +++ b/src/data/src/Contracts/BaseData.php @@ -125,4 +125,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/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/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 @@ + $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, @@ -75,6 +75,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/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 @@ + */ private ?array $unknownInput = null; + /** + * The rule inferrers resolved for the current compilation. + * + * @var list + */ + private array $ruleInferrers = []; + /** * @var array{ * class: null|class-string, @@ -113,6 +121,24 @@ 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); + } + /** * Write a mapped property value beneath the current path. * @@ -181,6 +207,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 +284,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. */ @@ -544,6 +600,7 @@ public function snapshotForProperty(string $property): self $payload = $this->payloadAtCurrentPath(); $snapshot->payload = self::payloadSkeleton($this->path(), $payload); $snapshot->unknownInput = null; + $snapshot->ruleInferrers = []; $templatePath = []; $exactPath = []; diff --git a/src/data/src/Support/Creation/DataCreator.php b/src/data/src/Support/Creation/DataCreator.php index cdb481e147..49efa93a56 100644 --- a/src/data/src/Support/Creation/DataCreator.php +++ b/src/data/src/Support/Creation/DataCreator.php @@ -166,6 +166,7 @@ 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]; @@ -187,7 +188,7 @@ public function resolveAutoLazyProperty( $value = $state->getValue($inputPath); } - return $this->castProperty($property, $value, $state, $extensions); + return $this->castProperty($property, $value, $state, $extensions, [], $castInputs); } /** @@ -783,7 +784,7 @@ protected function fillGeneralNode( $resolvedProperties = $this->resolveProperties($dataClass, $propertySources, $state->context); if ($state->context->prepareDataHooks !== []) { - $input = $this->mergeSources($dataClass, $propertySources, $state->context); + $input = $this->prepareDataInput($dataClass, $propertySources, $resolvedProperties, $state->context); foreach ($state->context->prepareDataHooks as $hook) { $input = $hook($input); @@ -800,6 +801,16 @@ protected function fillGeneralNode( $resolvedProperties = $this->resolveProperties($dataClass, $propertySources, $state->context); } + if ($dataClass->hasLifecycleMethod('prepareForPipeline')) { + $propertySources = $this->prepareSourcesForPipeline($dataClass, $propertySources, $state->context); + $resolvedProperties = $this->resolveProperties($dataClass, $propertySources, $state->context); + } + + 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) { @@ -907,6 +918,10 @@ protected function tryCreateDirectArrayNode( } if ($property->computed) { + if ($this->config->ignoreComputedPropertyInput) { + continue; + } + throw CannotSetComputedValue::create($property); } @@ -987,6 +1002,10 @@ protected function fillHookNode( $resolvedProperties = $this->resolveProperties($dataClass, [$source], $state->context); } + if ($compilesRules && is_array($source)) { + $state->writeNodeInput($source); + } + $state->setNodeClass($class); $this->fillResolvedProperties( $dataClass, @@ -1063,6 +1082,10 @@ protected function fillResolvedProperties( } if ($property->computed) { + if ($this->config->ignoreComputedPropertyInput) { + continue; + } + throw CannotSetComputedValue::create($property); } @@ -1455,6 +1478,10 @@ protected function reconcileNode( } if (! $value instanceof UnknownProperty && $property->computed) { + if ($this->config->ignoreComputedPropertyInput) { + continue; + } + throw CannotSetComputedValue::create($property); } @@ -1750,27 +1777,38 @@ protected function castAndInstantiateNode( $class = $state->nodeClass() ?? $state->context->dataClass; $dataClass = $this->dataClasses->get($class); $properties = []; + // The declared values given to user casts, built on the first one that needs them. + $castInputs = null; $contextualParameters = $dataClass->contextualParameters; foreach ($dataClass->properties as $property) { - if (isset($contextualParameters[$property->name])) { + if ($property->computed || isset($contextualParameters[$property->name])) { continue; } $wireKey = $state->originalKey($property->name); $inputPath = $property->inputPath($wireKey); + $value = $state->read($inputPath); - if (! $state->hasValue($inputPath)) { + if ($value instanceof UnknownProperty) { 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,8 +1818,14 @@ protected function castAndInstantiateNode( UnknownProperty::create(), $state, $extensions, + $properties, + $castInputs, ); + if ($castInputs !== null) { + $castInputs[$property->name] = $properties[$property->name]; + } + continue; } @@ -1798,10 +1842,25 @@ 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) { + $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 ($state->context->beforeCreationHooks as $hook) { @@ -1831,20 +1890,29 @@ protected function buildAutoLazy( mixed $value, ConstructionState $state, array &$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,6 +1923,7 @@ protected function buildAutoLazy( $resolvedValue, $state, $replay, + $inputs, ); }; @@ -1876,6 +1945,8 @@ protected function castProperty( mixed $value, ConstructionState $state, array &$extensions, + array $properties = [], + ?array &$castInputs = null, ): mixed { if ($value === null || $value instanceof Optional) { return $value; @@ -1894,8 +1965,12 @@ protected function castProperty( ? $this->propertyCasts($property, $state->context, $extensions) : []; + 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; @@ -1909,7 +1984,16 @@ protected function castProperty( $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) { @@ -1966,7 +2050,8 @@ protected function castProperty( $key = 'castable:' . $type->name; /** @var CastableCast $cast */ $cast = $extensions[$key] ??= new CastableCast($type->name); - $casted = $cast->cast($property, $value, $state, $state->context); + $castInputs ??= $this->castInputs($state, $properties); + $casted = $cast->cast($property, $value, $castInputs, $state->context); if (! $casted instanceof Uncastable) { return $casted; @@ -1980,10 +2065,11 @@ protected function castProperty( /** @var DateTimeInterfaceCast $cast */ $cast = $extensions[$key] ??= new DateTimeInterfaceCast(type: $dateType); + // The fixed built-in casts ignore sibling values, so none are built for them. return $cast->cast( $property, $value, - $state, + [], $state->context, ); } @@ -1998,7 +2084,7 @@ protected function castProperty( return $cast->cast( $property, $value, - $state, + [], $state->context, ); } @@ -2013,7 +2099,7 @@ protected function castProperty( return $cast->cast( $property, $value, - $state, + [], $state->context, ); } @@ -2101,6 +2187,8 @@ function (mixed $item) use ($dataClass, $state, &$extensions): BaseData { * * @param OperationMemo $extensions * @param list $casts + * @param array $properties + * @param null|array $castInputs */ protected function castTypedIterable( DataProperty $property, @@ -2109,12 +2197,23 @@ protected function castTypedIterable( ConstructionState $state, array &$extensions, array $casts, + array $properties, + ?array &$castInputs, ): mixed { + // 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 +2221,7 @@ protected function castTypedIterable( $state, $extensions, $casts, + $itemInputs, ); }); } @@ -2145,6 +2245,7 @@ protected function castTypedIterable( $state, $extensions, $casts, + $itemInputs, ); } @@ -2159,6 +2260,7 @@ protected function castTypedIterable( * * @param OperationMemo $extensions * @param list $casts + * @param array $castInputs */ protected function castIterableItem( DataProperty $property, @@ -2167,6 +2269,7 @@ protected function castIterableItem( ConstructionState $state, array &$extensions, array $casts, + array $castInputs, ): mixed { if ($value === null) { return $value; @@ -2177,7 +2280,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; @@ -2196,7 +2299,7 @@ protected function castIterableItem( $key = 'iterable-castable:' . $namedType->name; /** @var CastableCast $cast */ $cast = $extensions[$key] ??= new CastableCast($namedType->name); - $casted = $cast->cast($property, $value, $state, $state->context); + $casted = $cast->cast($property, $value, $castInputs, $state->context); if (! $casted instanceof Uncastable) { return $casted; @@ -2213,7 +2316,7 @@ protected function castIterableItem( return $cast->castIterableItem( $property, $value, - $state, + [], $state->context, ); } @@ -2228,7 +2331,7 @@ protected function castIterableItem( return $cast->castIterableItem( $property, $value, - $state, + [], $state->context, ); } @@ -2243,7 +2346,7 @@ protected function castIterableItem( return $cast->castIterableItem( $property, $value, - $state, + [], $state->context, ); } @@ -2271,6 +2374,89 @@ protected function resolveAutoLazy(DataProperty $property, array &$extensions): return $autoLazy; } + /** + * 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 because they are resolved when the object is constructed. + * + * @param array $properties values already cast for this node + * @return array + */ + protected function castInputs(ConstructionState $state, array $properties): array + { + $dataClass = $this->dataClasses->get($state->nodeClass() ?? $state->context->dataClass); + $inputs = []; + + foreach ($dataClass->properties as $property) { + $name = $property->name; + + if ($property->computed || isset($dataClass->contextualParameters[$name])) { + continue; + } + + if (array_key_exists($name, $properties)) { + $inputs[$name] = $properties[$name]; + + 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) { + $inputs[$name] = Optional::create(); + } elseif ($property->type->isNullable) { + $inputs[$name] = null; + } + } + + return $inputs; + } + + /** + * Determine if casting a property can reach a user cast that receives the declared values. + * + * @param OperationMemo $extensions + */ + protected function requiresCastInputs( + DataProperty $property, + CreationContext $context, + array &$extensions, + ): bool { + if ($this->propertyCasts($property, $context, $extensions) !== []) { + return true; + } + + foreach ($property->type->getNamedTypes() as $type) { + if ($type->isCastable + || ($type->iterableItemType !== null && $this->hasCastableType($type->iterableItemType)) + ) { + return true; + } + } + + return false; + } + + /** + * Determine if a type accepts a Castable class. + */ + protected function hasCastableType(Type $type): bool + { + foreach ($type->getNamedTypes() as $namedType) { + if ($namedType->isCastable) { + return true; + } + } + + return false; + } + /** * Get the ordered custom casts applicable to a property. * @@ -2407,7 +2593,7 @@ protected function resolveProperties( } /** - * 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} @@ -2418,16 +2604,26 @@ protected function resolveProperty( CreationContext $context, ): 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); - 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()]; } /** @@ -2454,8 +2650,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); + + if ($match !== null && ! $match[1] instanceof Optional) { return $payloads[$index]; } } @@ -2503,7 +2701,99 @@ 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, + ): array { + $class = $dataClass->name; + $prepared = []; + + foreach ($sources as $source) { + $prepared[] = $class::prepareForPipeline( + is_array($source) ? $source : $this->projectNormalizedSource($dataClass, $source, $context), + ); + } + + 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, + ): array { + if (count($sources) === 1) { + return is_array($sources[0]) + ? $sources[0] + : $this->projectNormalizedSource($dataClass, $sources[0], $context); + } + + $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 diff --git a/src/data/src/Support/DataConfig.php b/src/data/src/Support/DataConfig.php index e18361150d..b34ce035c2 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,6 +137,13 @@ 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', + ); } /** diff --git a/src/data/src/Support/Factories/DataClassFactory.php b/src/data/src/Support/Factories/DataClassFactory.php index 9afcb13259..c75bf8f482 100644 --- a/src/data/src/Support/Factories/DataClassFactory.php +++ b/src/data/src/Support/Factories/DataClassFactory.php @@ -475,6 +475,7 @@ protected function resolveLifecycleMethods(ReflectionClass $reflectionClass): ar 'withValidator', 'after', 'normalizers', + 'prepareForPipeline', 'stopOnFirstFailure', 'redirect', 'redirectRoute', @@ -570,6 +571,7 @@ protected function resolveCreationRecipe( if ($reflectionClass->isAbstract() || $propertyMorphable || isset($lifecycleMethods['normalizers']) + || isset($lifecycleMethods['prepareForPipeline']) || $this->config->normalizers !== []) { return null; } diff --git a/src/data/src/Support/Transformation/DataTransformer.php b/src/data/src/Support/Transformation/DataTransformer.php index 7245dd73ca..ac5702c093 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; @@ -62,10 +63,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, @@ -158,7 +161,11 @@ protected function transformData( bool $includeAdditionalData = true, ): array { if ($context->maxDepth !== null && $context->depth >= $context->maxDepth) { - throw MaxTransformationDepthReached::create($context->maxDepth); + if ($context->throwWhenMaxDepthReached) { + throw MaxTransformationDepthReached::create($context->maxDepth); + } + + return []; } $dataClass = $this->dataClasses->get($data::class); @@ -191,6 +198,15 @@ protected function transformData( } } + 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 = []; @@ -261,7 +277,11 @@ protected function transformCollectable( ?Collection $rootItems = null, ): array { if ($context->maxDepth !== null && $context->depth >= $context->maxDepth) { - throw MaxTransformationDepthReached::create($context->maxDepth); + if ($context->throwWhenMaxDepthReached) { + throw MaxTransformationDepthReached::create($context->maxDepth); + } + + return []; } $transformed = []; @@ -600,6 +620,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. */ diff --git a/src/data/src/Support/Transformation/PartialTree.php b/src/data/src/Support/Transformation/PartialTree.php index 84bf7760d4..060a8cccf9 100644 --- a/src/data/src/Support/Transformation/PartialTree.php +++ b/src/data/src/Support/Transformation/PartialTree.php @@ -12,11 +12,13 @@ * Create a compiled partial tree. * * @param array $children + * @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 = [], ) { } @@ -86,6 +88,8 @@ public function merge(?self $other): self } $children = []; + $all = $this->all || $other->all; + $nestedProperties = []; foreach ($this->children as $property => $child) { $children[$property] = $child->merge($other->child($property)); @@ -95,10 +99,17 @@ public function merge(?self $other): self $children[$property] ??= $child->merge($this->child($property)); } + foreach ($children as $property => $child) { + if (self::continuesBeneath($child, $all)) { + $nestedProperties[] = $property; + } + } + return new self( $this->selected || $other->selected, - $this->all || $other->all, + $all, $children, + $nestedProperties, ); } @@ -216,11 +227,26 @@ 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); + + if (self::continuesBeneath($children[$property], $all)) { + $nestedProperties[] = $property; + } } - return new self($tree['selected'], $all, $children); + return new self($tree['selected'], $all, $children, $nestedProperties); + } + + /** + * Determine if a child's selection continues into the child's own value. + * + * A child's own terminal * continues the selection; one inherited from its parent does not. + */ + private static function continuesBeneath(self $child, bool $parentAll): bool + { + return $child->children !== [] || ($child->all && ! $parentAll); } } 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/Validation/DataValidationCompiler.php b/src/data/src/Support/Validation/DataValidationCompiler.php index f438d7d3f2..1dab5290c7 100644 --- a/src/data/src/Support/Validation/DataValidationCompiler.php +++ b/src/data/src/Support/Validation/DataValidationCompiler.php @@ -6,14 +6,25 @@ use DateTimeInterface; use Hypervel\Contracts\Container\Container; +use Hypervel\Data\Attributes\Validation\ArrayType; +use Hypervel\Data\Attributes\Validation\BooleanType; +use Hypervel\Data\Attributes\Validation\IntegerType; +use Hypervel\Data\Attributes\Validation\Nullable; +use Hypervel\Data\Attributes\Validation\Numeric; +use Hypervel\Data\Attributes\Validation\Required; +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\Validation\Rules\RequiredIf as NativeRequiredIf; @@ -48,6 +59,7 @@ public function __construct( protected readonly DataClassRepository $dataClasses, protected readonly Container $container, protected readonly RuleDenormalizer $ruleDenormalizer, + protected readonly DataConfig $config, ) { } @@ -56,6 +68,7 @@ public function __construct( */ public function compile(ConstructionState $state): CompiledValidation { + $state->setRuleInferrers($this->resolveRuleInferrers()); $accumulator = new ValidationAccumulator; $compileUnknownFields = $state->unknownInput() !== null; $lifecycleDeclarations = [ @@ -93,6 +106,7 @@ public function compileCollection( ConstructionState $state, string $dataClass, ): CompiledValidation { + $state->setRuleInferrers($this->resolveRuleInferrers()); $accumulator = new ValidationAccumulator; $compileUnknownFields = $state->unknownInput() !== null; $lifecycleDeclarations = [ @@ -503,16 +517,10 @@ protected function propertyRules( bool $expectsArray, bool &$inferredRequired, ): array { - $attributeRules = []; - $hasPresenceRule = false; + $attributes = []; 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[] = $recipe->newInstance(); } $generatedRules = null; @@ -525,13 +533,60 @@ 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); + $hasPresenceRule = $hasPresenceRule || $this->hasPresenceRule($denormalizedRules); + } + } + + $inferredRules = $this->inferRules($property, $expectsArray, $hasPresenceRule); + $propertyRules = $this->applyRuleInferrers($property, $attributes, $inferredRules, $nodePath, $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)), + ); + } } else { - $generatedRules = $this->ruleDenormalizer->execute($generatedRules, $nodePath); + $attributeRules = []; + $hasPresenceRule = false; + + foreach ($attributes as $attribute) { + $denormalizedRules = $this->ruleDenormalizer->execute($attribute, $nodePath); + $hasPresenceRule = $hasPresenceRule + || $attribute instanceof RequiringRule + || $this->hasPresenceRule($denormalizedRules); + array_push($attributeRules, ...$denormalizedRules); + } + + if ($generatedRules !== null) { + $rules = $this->mergeRules( + $attributeRules, + $this->ruleDenormalizer->execute($generatedRules, $nodePath), + ); + } else { + $generatedRules = $this->inferRules($property, $expectsArray, $hasPresenceRule); + $inferredRequired = in_array('required', $generatedRules, true); + $rules = $this->mergeRules($attributeRules, $generatedRules); + } } - $rules = $this->mergeRules($attributeRules, $generatedRules); foreach ($state->context->afterRulesHooks as $hook) { $rules = $this->ruleDenormalizer->execute( @@ -546,7 +601,7 @@ protected function propertyRules( /** * Infer fixed presence and type rules for one property. * - * @return list + * @return list<'array'|'boolean'|'integer'|'nullable'|'numeric'|'required'|'sometimes'|'string'> */ protected function inferRules( DataProperty $property, @@ -573,6 +628,72 @@ protected function inferRules( return $rules; } + /** + * Run the configured rule inferrers over one property's attribute and inferred rules. + * + * Explicit attributes take precedence over inferred rules of the same type. + * + * @param list $attributes + * @param list<'array'|'boolean'|'integer'|'nullable'|'numeric'|'required'|'sometimes'|'string'> $inferredRules + */ + protected function applyRuleInferrers( + DataProperty $property, + array $attributes, + array $inferredRules, + ValidationPath $nodePath, + ConstructionState $state, + ): PropertyRules { + $rules = new PropertyRules($attributes); + + foreach ($inferredRules as $inferredRule) { + $rule = match ($inferredRule) { + 'sometimes' => new Sometimes, + 'nullable' => new Nullable, + 'required' => new Required, + 'array' => new ArrayType, + 'boolean' => new BooleanType, + 'numeric' => new Numeric, + 'integer' => new IntegerType, + 'string' => new StringType, + }; + + if (! $rules->hasType($rule::class)) { + $rules->add($rule); + } + } + + $context = new ValidationContext( + payload: $state->currentPayload(), + fullPayload: $state->payload(), + path: $nodePath, + ); + + 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. */ @@ -1193,11 +1314,14 @@ 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. + * + * @return null|'array'|'boolean'|'integer'|'numeric'|'string' */ protected function primitiveRule(DataProperty $property): ?string { diff --git a/src/data/src/Support/Validation/PropertyRules.php b/src/data/src/Support/Validation/PropertyRules.php new file mode 100644 index 0000000000..c87775504a --- /dev/null +++ b/src/data/src/Support/Validation/PropertyRules.php @@ -0,0 +1,109 @@ + $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. Any requiring rule removes every requiring rule. + * + * @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..ab95501400 --- /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 must fail; a null rule parameter would make database constraints check the wrong rows. + $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/docs/data-objects.md b/src/docs/data-objects.md index c260a65c05..9c8c496f4f 100644 --- a/src/docs/data-objects.md +++ b/src/docs/data-objects.md @@ -7,7 +7,9 @@ - [Creating Instances](#creating-instances) - [Associating a Data Class](#associating-a-data-class) - [Defaults, Null, and Optional Values](#defaults-null-and-optional-values) + - [Empty Representations](#empty-representations) - [Named Factories](#named-factories) + - [Preparing Input](#preparing-input) - [Property Name Conversion](#property-name-conversion) - [Type Conversion](#type-conversion) - [Date and Time Values](#date-and-time-values) @@ -189,13 +191,13 @@ $user = UserData::from([ `from` accepts arrays, JSON strings, `Arrayable` objects, initialized public properties from ordinary objects, Eloquent models, and requests. Existing instances of the requested data type pass through unchanged. -You may pass multiple payloads. For each property, the first payload containing its input key wins, including when that value is `null`: +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($routeValues, $requestValues, $defaults); +$user = UserData::from($defaults, $requestValues, $routeValues); ``` -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. +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`: @@ -304,12 +306,54 @@ 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`. When a named method returns the requested data object, Hypervel uses it directly without validating, casting, or running creation hooks for it again. This also applies to requests, so a named method that returns the finished object from a request is responsible for validating it. The data class's authorization still runs first. If the method returns another supported input value, Hypervel creates the object from that value without calling another named factory. 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. + +### Preparing Input + +Define a static `prepareForPipeline` method when a class needs to reshape its input before its properties are read: + +```php +use Hypervel\Support\Arr; + +class SongMetadataData extends Data +{ + public function __construct( + public string $year, + public string $producer, + ) { + } +} + +class SongData extends Data +{ + public function __construct( + public string $title, + public SongMetadataData $metadata, + ) { + } + + public static function prepareForPipeline(array $properties): array + { + $properties['metadata'] = Arr::only($properties, ['year', 'producer']); + + return $properties; + } +} + +$song = SongData::from([ + 'title' => '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 @@ -573,6 +617,22 @@ class InvoiceData extends Data A cast implements `Hypervel\Data\Casts\Cast`; a transformer implements `Hypervel\Data\Transformers\Transformer`. Return `Uncastable::create()` from a cast when the next applicable candidate should be tried. Returning `null` means the cast produced a real null value. +A cast's `cast` method receives the property, its input value, the object's declared property values keyed by property name, and the `CreationContext`. Undeclared input, contextual constructor values, and computed properties are not included. Properties that come earlier in the class have already been cast. Later properties contain their supplied input, or the default, `Optional`, or `null` value the object will receive when their input is absent: + +```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 new Money($value, $properties['currency']); + } +} +``` + Use `Castable` when a value class owns its input conversion, `IterableItemCast` when a cast also applies to typed iterable items, or `factory()->withCast()` for a single creation. Application-wide replacement casts and transformers belong in `config/data.php`; built-in date, enum, iterable, and `Arrayable` handling does not need to be configured. Custom normalizers convert a source value into input before Hypervel reads its properties. Declare normalizers for a data class with `normalizers()` or add them to a factory with `withNormalizers()`. Prefer a typed named factory when only one source type needs special handling. @@ -597,6 +657,33 @@ $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. +To adjust the rules of every data property, add your own rule inferrers to the `data.rule_inferrers` configuration option. An inferrer implements `Hypervel\Data\RuleInferrers\RuleInferrer`. After Hypervel's own inference, it receives the property, its rules, and a `ValidationContext`: + +```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; + } +} +``` + +Validation attributes take precedence over inferred rules of the same type. Inferrers are resolved from the container for each validation, so they may depend on scoped services. + +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. @@ -621,7 +708,32 @@ class UserData extends Data } ``` -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. +Database-aware `Exists` and `Unique` attributes support the familiar fluent constraints. Use a `FieldReference` to refer to another input field rather than interpolating its name into a string. + +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. ### Manual Rules and Hooks @@ -640,7 +752,7 @@ 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 merge instead. Property keys use PHP property names; Hypervel translates them to the input paths selected for the current payload. 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. @@ -684,7 +796,7 @@ Factories may change the validation strategy, enable or disable name mapping and 7. `beforeCreation` 8. `afterCreation` -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. +The `prepareData`, `beforeCreation`, and `afterCreation` hooks run even when validation is skipped. The other hooks run while generating rules or validating, as appropriate. The `beforeValidation` hook receives the complete input and may add fields for the rules to read, while `afterValidation` receives the validated payload. Call `alwaysValidate()` when validation hooks should also apply to an array, model, JSON value, or another non-request source. Each call to `factory()` returns a new factory. Keep a reused factory scoped to the current operation instead of storing it across requests. @@ -703,6 +815,8 @@ $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()`. +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. @@ -749,14 +863,16 @@ 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. +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. + +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. 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()`: @@ -808,7 +924,21 @@ 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. -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()`. +Override static `jsonOptions()` or `withResponse(Request $request, JsonResponse $response)` for Laravel-style response customization. A custom data collection class may also override `withResponse()`, while its JSON options come from its data class. Responses use the `200` status code for every request method, so set a `201` status for a newly created object yourself: + +```php +use Hypervel\Http\JsonResponse; +use Hypervel\Http\Request; + +public function withResponse(Request $request, JsonResponse $response): void +{ + if ($request->isMethod('POST')) { + $response->setStatusCode(201); + } +} +``` + +Query-string `include`, `exclude`, `only`, and `except` selections are disabled unless the data class allows them through `allowedRequestIncludes()`, `allowedRequestExcludes()`, `allowedRequestOnly()`, or `allowedRequestExcept()`. ## Form Request Casting @@ -992,10 +1122,18 @@ Saloon attaches the response to the data object through its existing `WithRespon Generate a class with the `make:data` command: ```shell -php bin/hypervel.php make:data UserData +php artisan make:data User +``` + +By default, the class is placed in the `App\Data` namespace and suffixed with `Data`, so this command creates `App\Data\UserData`. A name that already ends with the suffix 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. + +For a single class, pass `--suffix` to use a different suffix or `--target-namespace` to choose the complete namespace: + +```shell +php artisan make:data Post --suffix=Dto --target-namespace="App\DataTransferObjects" ``` -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`. +Like Hypervel's other generators, the command honors an application stub override and supports `--force`. ## Worker Lifetime @@ -1004,7 +1142,7 @@ 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. +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 the `data.var_dumper_caster_mode` configuration option to `enabled` to use it in every environment, or `disabled` to dump the complete object. 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. diff --git a/src/docs/porting-from-laravel.md b/src/docs/porting-from-laravel.md index 9d17fe5edd..eefe334f9f 100644 --- a/src/docs/porting-from-laravel.md +++ b/src/docs/porting-from-laravel.md @@ -576,7 +576,18 @@ When porting `spatie/laravel-data`, replace its namespace with `Hypervel\Data` a 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`. +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. Typed iterables are always cast and transformed. + +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 custom cast's `$properties` contains only declared property values keyed by PHP property name, without undeclared input or raw input names. +- Replace `getDataContext()` with `getPartialsDefinition()` and `getWrap()`, and `make:data --namespace` with `--target-namespace` and a complete namespace. ### Rate Limiting diff --git a/tests/Benchmarks/Data/benchmark.php b/tests/Benchmarks/Data/benchmark.php index 5c09fe7466..a4bb0c39ed 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'); @@ -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, 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/Casts/BuiltinTypeCastTest.php b/tests/Data/Casts/BuiltinTypeCastTest.php index 7474c8aef8..0d8c2e2cf0 100644 --- a/tests/Data/Casts/BuiltinTypeCastTest.php +++ b/tests/Data/Casts/BuiltinTypeCastTest.php @@ -6,7 +6,6 @@ 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; @@ -21,12 +20,11 @@ class BuiltinTypeCastTest extends TestCase public function testCastsBuiltInValues(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)); } /** diff --git a/tests/Data/Casts/DateTimeInterfaceCastTest.php b/tests/Data/Casts/DateTimeInterfaceCastTest.php index fde44d4ed4..9148453683 100644 --- a/tests/Data/Casts/DateTimeInterfaceCastTest.php +++ b/tests/Data/Casts/DateTimeInterfaceCastTest.php @@ -16,7 +16,6 @@ use Hypervel\Data\Contracts\BaseData; 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; @@ -36,7 +35,7 @@ class DateTimeInterfaceCastTest extends TestCase */ 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 +50,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 +62,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 +80,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 +91,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 +103,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,7 +120,7 @@ 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')); @@ -133,15 +132,15 @@ public function testTimezoneConversionPreservesExactConcreteTypes(): void */ public function testCastsIterableDatesAndDeclinesNonDateProperties(): void { - [$state, $context] = $this->operation(['Y-m-d']); + [$properties, $context] = $this->operation(['Y-m-d']); $cast = new DateTimeInterfaceCast; - $date = $cast->castIterableItem($this->property('dates'), '2026-08-30', $state, $context); + $date = $cast->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), + $cast->cast($this->property('name'), '2026-08-30', $properties, $context), ); } @@ -150,7 +149,7 @@ public function testCastsIterableDatesAndDeclinesNonDateProperties(): void */ public function testThrowsWhenNoDateFormatMatches(): void { - [$state, $context] = $this->operation(['Y-m-d']); + [$properties, $context] = $this->operation(['Y-m-d']); $this->expectException(CannotCastDate::class); $this->expectExceptionMessageIsOrContains(DateTimeImmutable::class); @@ -159,7 +158,7 @@ public function testThrowsWhenNoDateFormatMatches(): void (new DateTimeInterfaceCast)->cast( $this->property('immutable'), 'not-a-date', - $state, + $properties, $context, ); } @@ -169,7 +168,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 +176,7 @@ public function testThrowsForAbstractDateTarget(): void (new DateTimeInterfaceCast)->cast( $this->property('abstract'), '2026-08-30', - $state, + $properties, $context, ); } @@ -210,16 +209,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]; + )]; } } diff --git a/tests/Data/Casts/EnumCastTest.php b/tests/Data/Casts/EnumCastTest.php index 4e32c0e217..5edea66a1b 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; @@ -29,13 +28,13 @@ class EnumCastTest extends TestCase */ public function testCastsDeclaredBackedEnumValues(): 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)); } /** @@ -43,11 +42,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,14 +55,14 @@ 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, ), ); @@ -74,11 +73,11 @@ public function testCastsIterableBackedEnumValues(): void */ public function testReturnsUncastableWithoutABackedEnumDeclaration(): void { - [$state, $context] = $this->operation(); + [$properties, $context] = $this->operation(); $this->assertSame( Uncastable::create(), - (new EnumCast)->cast($this->property('name'), 'ready', $state, $context), + (new EnumCast)->cast($this->property('name'), 'ready', $properties, $context), ); } @@ -87,12 +86,12 @@ public function testReturnsUncastableWithoutABackedEnumDeclaration(): void */ public function testThrowsForAnInvalidBackedEnumValue(): void { - [$state, $context] = $this->operation(); + [$properties, $context] = $this->operation(); $this->expectException(CannotCastEnum::class); $this->expectExceptionMessageIsOrContains('EnumCastDataFixture::$status'); - (new EnumCast)->cast($this->property('status'), 'invalid', $state, $context); + (new EnumCast)->cast($this->property('status'), 'invalid', $properties, $context); } /** @@ -121,13 +120,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)]; } } 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/DataServiceProviderTest.php b/tests/Data/DataServiceProviderTest.php index cf8742ec36..1735537318 100644 --- a/tests/Data/DataServiceProviderTest.php +++ b/tests/Data/DataServiceProviderTest.php @@ -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/Http/ResourceResponseTest.php b/tests/Data/Http/ResourceResponseTest.php index 6b0cbad1eb..cf61b980bd 100644 --- a/tests/Data/Http/ResourceResponseTest.php +++ b/tests/Data/Http/ResourceResponseTest.php @@ -98,6 +98,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')); @@ -365,6 +375,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/Support/Creation/DataCreatorTest.php b/tests/Data/Support/Creation/DataCreatorTest.php index 2d3a80c011..ef59c6db10 100644 --- a/tests/Data/Support/Creation/DataCreatorTest.php +++ b/tests/Data/Support/Creation/DataCreatorTest.php @@ -22,6 +22,8 @@ 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 +40,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; @@ -49,6 +50,7 @@ use Hypervel\Database\Eloquent\Model; use Hypervel\Http\Request; use Hypervel\Pagination\Paginator; +use Hypervel\Support\Arr; use Hypervel\Support\Collection; use Hypervel\Support\LazyCollection; use Hypervel\Testbench\Attributes\DefineEnvironment; @@ -85,11 +87,11 @@ public function testCreatesMappedDataWithCastsAndFixedAbsenceSemantics(): void $this->assertInstanceOf(Optional::class, $data->note); } - public function testFirstSourceContainingAPropertyWinsAndMappingCanBeDisabled(): void + 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 +100,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(); @@ -1280,6 +1343,68 @@ public function testExplicitCastOwnsAnAmbiguousDataCollectableUnion(): void $this->assertSame(7, $data->children->first()->id); } + 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 testUnrelatedUnionArmPassesThroughAmbiguousDataCollectableTypes(): void { $data = AmbiguousDataCollectableCreationData::from(['children' => 'unchanged']); @@ -1287,6 +1412,64 @@ public function testUnrelatedUnionArmPassesThroughAmbiguousDataCollectableTypes( $this->assertSame('unchanged', $data->children); } + public function testCanRestructurePayloadBeforeEnteringThePipeline(): void + { + $instance = PreparedAddressData::from([ + 'name' => '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(), + ); + } + + 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() @@ -1334,6 +1517,27 @@ public function testRejectsSuppliedComputedValuesAndInvalidAfterCreationResults( ->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. * @@ -1359,6 +1563,14 @@ 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); + } } class BasicCreationData extends Data @@ -1373,6 +1585,45 @@ public function __construct( } } +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; @@ -1676,7 +1927,7 @@ public function __construct() public function cast( DataProperty $property, mixed $value, - ConstructionState $state, + array $properties, CreationContext $context, ): int { return (int) $value; @@ -2415,7 +2666,7 @@ class PriorityCreationCast implements Cast public function cast( DataProperty $property, mixed $value, - ConstructionState $state, + array $properties, CreationContext $context, ): PriorityCreationCastable { return new PriorityCreationCastable((string) $value); @@ -2464,7 +2715,7 @@ class AmbiguousDataCollectableCreationCast implements Cast public function cast( DataProperty $property, mixed $value, - ConstructionState $state, + array $properties, CreationContext $context, ): Collection { return new Collection([new ChildCreationData((int) $value)]); @@ -2520,7 +2771,7 @@ class CreationIdentifierCast implements Cast public function cast( DataProperty $property, mixed $value, - ConstructionState $state, + array $properties, CreationContext $context, ): int { return 123; @@ -2532,13 +2783,271 @@ class CreationLabelCast implements Cast public function cast( DataProperty $property, mixed $value, - ConstructionState $state, + array $properties, CreationContext $context, ): string { return 'cast:' . $value; } } +class RecordingInputsCast implements Cast +{ + /** @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; + } +} + +class RecordingItemInputsCast implements Cast, IterableItemCast +{ + /** @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(); + } + + /** + * 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 CastInputsDefault +{ +} + +class CastInputsData extends Data +{ + #[Computed] + public string $summary; + + /** + * Create a cast-inputs fixture. + */ + public function __construct( + 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, + ) { + $this->summary = $recorded; + } +} + +class CastInputsLeadingDefaultData extends Data +{ + /** + * Create a cast-inputs fixture whose default precedes the cast property. + */ + public function __construct( + public CastInputsDefault $default = new CastInputsDefault, + #[WithCast(RecordingInputsCast::class)] + public string $recorded = 'fallback', + ) { + } +} + +class CastInputsIterableData extends Data +{ + /** + * Create an iterable cast-inputs fixture. + * + * @param list $tags + */ + public function __construct( + public string $name, + #[WithCast(RecordingItemInputsCast::class)] + public array $tags, + ) { + } +} + +class CastInputsLazyData extends Data +{ + /** + * Create a deferred cast-inputs fixture. + */ + public function __construct( + public int $first, + #[AutoLazy, WithCast(RecordingInputsCast::class)] + public Lazy|string $recorded, + ) { + } +} + +class FactoryCastInputsLazyData extends Data +{ + /** + * Create a deferred fixture for a factory-supplied cast. + */ + public function __construct( + public int $first, + #[AutoLazy] + public Lazy|string $recorded, + ) { + } +} + +class PreparedAddressData extends Data +{ + public static int $calls = 0; + + /** + * Create a prepared address fixture. + */ + public function __construct( + public ?string $name = null, + public ?string $address = null, + ) { + } + + /** + * Join the flat address fields into one address. + */ + public static function prepareForPipeline(array $properties): array + { + ++static::$calls; + + $properties['address'] = implode(',', Arr::only($properties, ['line_1', 'city', 'state', 'zipcode'])); + + return $properties; + } +} + +class PreparedRequiredAddressData extends Data +{ + /** + * Create a prepared required-address fixture. + */ + public function __construct(public string $address) + { + } + + /** + * Join the flat address fields into the required address. + */ + public static function prepareForPipeline(array $properties): array + { + $properties['address'] = implode(', ', Arr::only($properties, ['line_1', 'city'])); + + return $properties; + } +} + +class PreparedOwnerData extends Data +{ + /** + * Create a fixture whose nested owner prepares its own input. + */ + public function __construct( + public string $label, + public PreparedRequiredAddressData $owner, + ) { + } +} + +class PreparedNameModel extends Model +{ +} + +class PreparedNameData extends Data +{ + /** + * Create a prepared name fixture. + */ + public function __construct(public string $name) + { + } + + /** + * Uppercase the name before it is read. + */ + public static function prepareForPipeline(array $properties): array + { + $properties['name'] = strtoupper($properties['name']); + + return $properties; + } +} + +abstract class PreparedShapeData extends Data implements PropertyMorphableData +{ + /** + * Create a prepared shape fixture. + */ + public function __construct( + #[PropertyForMorph] + public string $type, + ) { + } + + /** + * Resolve the concrete shape class. + */ + public static function morph(array $properties): ?string + { + return $properties['type'] === 'circle' ? PreparedCircleData::class : null; + } +} + +class PreparedCircleData extends PreparedShapeData +{ + /** + * Create a prepared circle fixture. + */ + public function __construct(string $type, public int $radius) + { + 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 ComputedCreationData extends Data { #[Computed] diff --git a/tests/Data/Support/DataClassTest.php b/tests/Data/Support/DataClassTest.php index b3db4f062b..aeb9834f5b 100644 --- a/tests/Data/Support/DataClassTest.php +++ b/tests/Data/Support/DataClassTest.php @@ -32,7 +32,6 @@ use Hypervel\Data\Normalizers\Normalized\Normalized; use Hypervel\Data\Normalizers\Normalizer; use Hypervel\Data\Support\Annotations\DataIterableAnnotationReader; -use Hypervel\Data\Support\Creation\ConstructionState; use Hypervel\Data\Support\Creation\CreationContext; use Hypervel\Data\Support\DataConfig; use Hypervel\Data\Support\DataProperty; @@ -260,6 +259,7 @@ public function testCreationRecipeEligibilityUsesCompiledClassAndPropertyFacts() AbstractDirectArrayCreationDataFixture::class, MorphableDirectArrayCreationDataFixture::class, ClassNormalizerDirectArrayCreationDataFixture::class, + PreparedDirectArrayCreationDataFixture::class, PromotedContextualDataFixture::class, AutoLazyDirectArrayCreationDataFixture::class, LoadRelationDirectArrayCreationDataFixture::class, @@ -670,6 +670,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 +731,7 @@ class DirectArrayCreationCast implements Cast public function cast( DataProperty $property, mixed $value, - ConstructionState $state, + array $properties, CreationContext $context, ): mixed { return $value; diff --git a/tests/Data/Support/DataPropertyTest.php b/tests/Data/Support/DataPropertyTest.php index 3cbe4096d4..5310aa4fb4 100644 --- a/tests/Data/Support/DataPropertyTest.php +++ b/tests/Data/Support/DataPropertyTest.php @@ -21,7 +21,6 @@ use Hypervel\Data\Mappers\KebabCaseMapper; use Hypervel\Data\Mappers\SnakeCaseMapper; use Hypervel\Data\Optional; -use Hypervel\Data\Support\Creation\ConstructionState; use Hypervel\Data\Support\Creation\CreationContext; use Hypervel\Data\Support\DataConfig; use Hypervel\Data\Support\DataProperty; @@ -336,7 +335,7 @@ class PropertyCast implements Cast public function cast( DataProperty $property, mixed $value, - ConstructionState $state, + array $properties, CreationContext $context, ): mixed { return $value; diff --git a/tests/Data/Support/Transformation/DataTransformerTest.php b/tests/Data/Support/Transformation/DataTransformerTest.php index 07c0ec51a6..489fb26e82 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,7 +42,9 @@ use Hypervel\Inertia\OptionalProp; use Hypervel\Pagination\CursorPaginator; use Hypervel\Pagination\LengthAwarePaginator; +use Hypervel\Testbench\Attributes\WithConfig; use Hypervel\Testbench\TestCase; +use PHPUnit\Framework\Attributes\DataProvider; use ReflectionProperty; use RuntimeException; use Traversable; @@ -912,6 +916,90 @@ public function testThrowsAtMaximumTransformationDepth(): void $data->transform(TransformationContextFactory::create()->maxDepth(1)); } + public function testReturnsAnEmptyArrayAtMaximumDepthWhenNotThrowing(): void + { + $nested = new NestedData(new NestedData(new SimpleData('deep'))); + $collection = new NestedCollectionOwnerData( + new DataCollection(NestedLazyData::class, [new NestedLazyData('first', 'kept')]), + ); + + $this->assertSame( + ['nested' => []], + $nested->transform(TransformationContextFactory::create()->maxDepth(1, throw: false)), + ); + $this->assertSame( + ['collection' => []], + $collection->transform(TransformationContextFactory::create()->maxDepth(1, throw: false)), + ); + } + + #[DataProvider('partialOperationProvider')] + public function testNestedPartialsOnUnknownPropertiesThrow(string $operation): void + { + $this->expectException(CannotPerformPartialOnDataField::class); + $this->expectExceptionMessage( + "Cannot apply the [{$operation}] partial to unknown data property [certainly-not-simple]", + ); + + PartialTargetData::make()->{$operation}('certainly-not-simple.string')->toArray(); + } + + /** + * Get the partial operations that validate nested paths. + * + * @return array + */ + public static function partialOperationProvider(): array + { + return [ + 'include' => ['include'], + 'exclude' => ['exclude'], + 'only' => ['only'], + 'except' => ['except'], + ]; + } + + 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.ignore_invalid_partials', true)] + public function testInvalidNestedPartialsCanBeIgnored(): void + { + $this->assertSame( + ['string' => 'World'], + PartialTargetData::make()->include('certainly-not-simple.string', 'string')->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->assertSame(['nested' => []], $data->toArray()); + + $this->expectException(MaxTransformationDepthReached::class); + + $data->transform(TransformationContextFactory::forPersistence()); + } + /** * Test persistence transforms the complete constructable graph without changing partial stores. */ @@ -1098,6 +1186,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( diff --git a/tests/Data/Support/Transformation/PartialTreeTest.php b/tests/Data/Support/Transformation/PartialTreeTest.php index 2845588bfa..f78fcbb1df 100644 --- a/tests/Data/Support/Transformation/PartialTreeTest.php +++ b/tests/Data/Support/Transformation/PartialTreeTest.php @@ -28,6 +28,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 +37,7 @@ public function testCompilesNestedGroupedAndWildcardPaths(): void $this->assertTrue($artist->selects('email')); $this->assertTrue($artist->selects('role')); $this->assertFalse($artist->selects('id')); + $this->assertSame([], $artist->nestedProperties); $songs = $tree->child('songs'); @@ -83,6 +85,10 @@ 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); $unlisted = $all->child('unlisted'); @@ -108,6 +114,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)); } diff --git a/tests/Data/Support/Transformation/TransformationContextFactoryTest.php b/tests/Data/Support/Transformation/TransformationContextFactoryTest.php index 69a32f8a60..70ac909b10 100644 --- a/tests/Data/Support/Transformation/TransformationContextFactoryTest.php +++ b/tests/Data/Support/Transformation/TransformationContextFactoryTest.php @@ -47,6 +47,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 +69,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 +105,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/DataValidatorTest.php b/tests/Data/Support/Validation/DataValidatorTest.php index c7551aee90..d0cf185bb6 100644 --- a/tests/Data/Support/Validation/DataValidatorTest.php +++ b/tests/Data/Support/Validation/DataValidatorTest.php @@ -4,18 +4,26 @@ namespace Hypervel\Tests\Data\Support\Validation; +use Attribute; 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\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\Max; use Hypervel\Data\Attributes\Validation\Required; use Hypervel\Data\Attributes\Validation\RequiredUnless; +use Hypervel\Data\Attributes\Validation\Sometimes; use Hypervel\Data\Attributes\Validation\StringType; use Hypervel\Data\Attributes\WithoutValidation; use Hypervel\Data\Contracts\PropertyMorphableData; @@ -27,7 +35,10 @@ 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,6 +50,7 @@ use Hypervel\Http\Request; use Hypervel\Support\Collection; use Hypervel\Support\LazyCollection; +use Hypervel\Testbench\Attributes\WithConfig; use Hypervel\Testbench\TestCase; use Hypervel\Validation\Factory as ValidationFactory; use Hypervel\Validation\ValidationException; @@ -47,6 +59,8 @@ use ReflectionMethod; use Symfony\Component\HttpKernel\Exception\HttpException; +use function Hypervel\Coroutine\parallel; + class DataValidatorTest extends TestCase { /** @@ -121,6 +135,65 @@ public function testValidateAndCreateUsesTheSameRulesBeforeCasting(): void $this->assertSame(12, $data->id); } + /** + * 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']]), + ); + } + /** * Test inferred rules follow the declared presence and primitive types. */ @@ -134,6 +207,125 @@ public function testExposesInferredValidationRules(): void $this->assertSame(['string'], $rules['label']); } + /** + * 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(['max:20', 'required', 'string'], $rules['code']); + $this->assertSame(['array:id', 'required'], $rules['meta']); + $this->assertSame(['required', 'integer'], $rules['age']); + } + + /** + * 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 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(['max:10', 'alpha', 'required', 'string'], $rules['name']); + $this->assertSame(1, $resolutions); + $this->assertSame(1, CountingRuleAttribute::$evaluations); + } + + /** + * Test class rules and rule inferrers receive undeclared input at every level. + */ + #[WithConfig('data.rule_inferrers', [RecordingContextRuleInferrer::class])] + public function testValidationContextsIncludeUndeclaredInput(): void + { + RecordingContextRuleInferrer::$contexts = []; + ContextParentDataFixture::$contexts = []; + $payload = ['mode' => 'admin', 'child' => ['kind' => 'primary', 'value' => 'a']]; + + ContextParentDataFixture::getValidationRules($payload); + + $this->assertSame([$payload, $payload], ContextParentDataFixture::$contexts['parent']); + $this->assertSame([$payload['child'], $payload], ContextParentDataFixture::$contexts['child']); + $this->assertSame([$payload['child'], $payload], RecordingContextRuleInferrer::$contexts['value']); + } + /** * Test mixed collection wire choices produce exact mapped error paths. */ @@ -614,6 +806,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. */ @@ -1372,6 +1579,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. */ @@ -1865,6 +2107,303 @@ public function __construct( } } +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 RecordingContextRuleInferrer implements RuleInferrer +{ + /** @var array */ + public static array $contexts = []; + + /** + * Record the property's validation context. + */ + public function handle(DataProperty $property, PropertyRules $rules, ValidationContext $context): PropertyRules + { + static::$contexts[$property->name] = [$context->payload, $context->fullPayload]; + + return $rules; + } +} + +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 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, + ) { + } +} + +#[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( @@ -2345,6 +2884,32 @@ public static function rules(): array } } +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( 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..92a9e01f57 --- /dev/null +++ b/tests/Data/Support/Validation/References/AuthenticatedUserReferenceTest.php @@ -0,0 +1,111 @@ +make('config')->set('auth.guards.admin', ['driver' => 'session', 'provider' => 'users']); + } + + public function testCanReferenceTheCurrentLoggedInUserAsValuesWithinRules(): void + { + $this->actingAs($user = $this->user(69)); + + $rules = AuthenticatedUserIgnoreData::getValidationRules([]); + + $this->assertSame((string) Rule::unique('users')->ignore($user), (string) $rules['property'][0]); + } + + public function testCanReferenceAScalarPropertyOfTheCurrentUser(): void + { + $this->actingAs($this->user(1, ['max_title_length' => 40])); + + $rules = AuthenticatedUserPropertyData::getValidationRules([]); + + $this->assertSame('max:40', $rules['title'][0]); + } + + 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'][0]); + $this->assertSame('max:20', $second['title'][0]); + } + + /** + * 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 AuthenticatedUserIgnoreData extends Data +{ + /** + * Create a fixture that ignores the authenticated user. + */ + public function __construct( + #[Unique('users', ignore: new AuthenticatedUserReference)] + public int $property, + ) { + } +} + +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..a9bd13aa75 --- /dev/null +++ b/tests/Data/Support/Validation/References/ContainerReferenceTest.php @@ -0,0 +1,59 @@ +app->bind('max-allowed-size', static fn (): int => 100); + + $rules = ContainerReferenceMaxData::getValidationRules([]); + + $this->assertSame('max:100', $rules['property'][0]); + } + + public function testResolvesAPropertyOfTheDependency(): void + { + $this->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..bc2dfa386d --- /dev/null +++ b/tests/Data/Support/Validation/References/RouteParameterReferenceTest.php @@ -0,0 +1,169 @@ +bindRouteParameters(['active' => true]); + + $rules = RouteParameterWhereConstraintData::getValidationRules([]); + + $this->assertSame('unique:users,NULL,NULL,id,is_active,"1"', (string) $rules['email'][0]); + $this->assertSame(['required', 'string'], array_slice($rules['email'], 1)); + } + + public function testCanReferenceRouteParametersAsValuesWithinRules(): void + { + $this->bindRouteParameters(['post_id' => '69']); + + $rules = RouteParameterIgnoreData::getValidationRules([]); + + $this->assertSame('unique:posts,NULL,"69",id', (string) $rules['property'][0]); + } + + public function testCanReferenceRouteModelsWithAPropertyAsValuesWithinRules(): void + { + $this->bindRouteParameters(['post' => RouteParameterPost::make(69)]); + + $rules = RouteParameterModelPropertyData::getValidationRules([]); + + $this->assertSame('unique:posts,NULL,"69",id', (string) $rules['property'][0]); + } + + public function testResolvesTheWholeBoundModelWithoutAProperty(): void + { + $post = RouteParameterPost::make(69); + $this->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'][0]); + $this->assertSame('unique:posts,NULL,"2",id', (string) $second['property'][0]); + } + + /** + * Seed the current request with a route carrying the given parameters. + * + * @param array $parameters + */ + private function bindRouteParameters(array $parameters): void + { + $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); + } +} + +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 RouteParameterWhereConstraintData extends Data +{ + /** + * Create a fixture constrained by a route parameter. + */ + public function __construct( + #[Unique('users', where: [new WhereConstraint('is_active', new RouteParameterReference('active'))])] + public string $email, + ) { + } +} + +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, + ) { + } +} + +class RouteParameterModelPropertyData extends Data +{ + /** + * Create a fixture that ignores a route model's property. + */ + public function __construct( + #[Unique('posts', ignore: new RouteParameterReference('post', 'id'))] + public int $property, + ) { + } +} 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; From 323096b95a25a17cb523b5a420ac8b23e93a31da Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 3 Oct 2026 20:00:15 +0000 Subject: [PATCH 02/14] Record sync checkpoints for Wayfinder, Laravel docs, Inertia and Data Set the queue targets as the checked-through revisions for laravel/wayfinder main, laravel/docs 13.x, inertiajs/inertia-laravel 3.x and spatie/laravel-data main. Data examined no pull requests, so its last reviewed PR stays unset. --- docs/upstream-sync/sync.yaml | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/upstream-sync/sync.yaml b/docs/upstream-sync/sync.yaml index f5565e69be..f73c9f4437 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,9 @@ 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. spatie/laravel-permission: From 3a00a662fa4a718ddba3c14f857714ba399fd7fa Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 4 Oct 2026 09:51:42 +0000 Subject: [PATCH 03/14] Scale the Data benchmark's expensive scenarios to shorter samples The 1,000-item collection and Eloquent scenarios measured 200 operations per sample and validate-5000-nested 20, so single samples ran for 1.5 to 18 seconds and a full default run took about 15 minutes. They now run 20 and 2 operations per sample; the shortest sample is still about 40 ms, long enough that scheduler noise does not dominate, and a full run takes about 2.5 minutes. The faster scenarios keep their operation counts. The README now asks for the owner's confirmation that the machine is idle before running a benchmark, because background load skews the results. --- tests/Benchmarks/Data/README.md | 2 ++ tests/Benchmarks/Data/benchmark.php | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) 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 a4bb0c39ed..18d1d5405b 100644 --- a/tests/Benchmarks/Data/benchmark.php +++ b/tests/Benchmarks/Data/benchmark.php @@ -493,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); From b98318c61539fef5de9d5b350f90996a2a9f6bd2 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 4 Oct 2026 09:52:26 +0000 Subject: [PATCH 04/14] Reconcile Laravel Data creation tests and fix the defects they exposed Port the upstream creation coverage from spatie/laravel-data main (ce296f2): CreationTest and its shared fixtures, merged with the existing cast, metadata, PHPDoc, creation, validation and transformation-context tests under upstream names. Excluded features carry REMOVED comments (EnumerableCast, UnserializeCast, deprecated collection classes, withoutOptionalValues() and custom pipes). Fixes, each with a regression test: - Explicit casts on data object and collection properties receive the input before any of its objects are built. Under validation, the input beneath the cast is prepared and validated by the ordinary Fill in the same construction state; named factories and object construction wait until the cast declines, and a matching factory is no validation exemption. Without validation the cast receives the raw value. - Named object factories must return the requested object, as upstream requires; the Hypervel-only mode that continued from another returned value is removed (owner-approved). - Unions with several container types or a data object record the type the raw value selects, per collection item, and Fill, hook reconciliation, validation and casting all read it. A normalized child is no longer mistaken for an accepted array, typed container arms cast their items, and ambiguous containers are rejected. A union value's inferred type rule follows the type that holds it. - Constructor-only inputs receive their raw input by name, governed only by declared rules (owner-approved). - Absent properties outside the constructor keep constructor-assigned values, Optional input counts as absent, and ancestor-promoted properties ignore input. - Models: names from an overridden getAttribute() are read; an explicit null stays a supplied null (owner-approved), while strict-mode missing attributes and unselected columns without a getter are absent. - collect(null, $into) returns an empty non-paginator target; factory and configured item casts apply; untyped containers convert; float items widen integers. - Anonymous-class PHPDoc names resolve in the declaring file's namespace, and item shorthands apply to any container after exact matches. - Input is normalized once: SourceResolver reports unreadable input and the filling step decides between an error and validation. Structure: extension resolution and reuse move from a by-reference memo threaded through the engine into a per-operation CreationExtensions object, cloned from an empty instance because a constructor call per operation measurably slowed the direct path. A class docblock records the engine's invariants. The Fill, reconciliation and construction phases stay together because they share recorded decisions and re-enter each other. The README, data-objects documentation, porting guide and sync note describe the kept union values, model nulls, constructor-only inputs, cast input and finished-object factories. Verified with the Data suite (988 tests), PHPStan, the SDK generator suite (2186 tests) against this branch, and the benchmark harness against aa08c27 on an idle machine: from -2% to +5% across the measured scenarios, about 45 ns per root operation of it from the extensions object. --- docs/upstream-sync/sync.yaml | 2 +- src/data/README.md | 18 +- src/data/src/Concerns/BaseData.php | 2 +- src/data/src/Contracts/BaseData.php | 2 +- src/data/src/Exceptions/CannotCreateData.php | 20 +- .../src/Exceptions/InvalidDataDeclaration.php | 33 - .../Normalized/NormalizedModel.php | 25 +- .../Support/Creation/ConstructionState.php | 147 +- .../Creation/CreationContextFactory.php | 2 +- .../Support/Creation/CreationExtensions.php | 222 ++ src/data/src/Support/Creation/DataCreator.php | 717 +++--- .../src/Support/Creation/DataInstantiator.php | 26 +- .../src/Support/Creation/SourceResolver.php | 13 +- src/data/src/Support/DataClass.php | 2 + src/data/src/Support/DataClassRepository.php | 7 +- src/data/src/Support/DataProperty.php | 2 + src/data/src/Support/DataPropertyType.php | 84 + .../Support/Factories/DataClassFactory.php | 49 +- .../Support/Factories/DataPropertyFactory.php | 20 +- .../src/Support/Factories/DataTypeFactory.php | 10 +- .../Support/Types/PhpDocTypeNameResolver.php | 62 +- .../Support/Validation/CompiledValidation.php | 25 +- .../Validation/DataValidationCompiler.php | 182 +- .../src/Support/Validation/DataValidator.php | 2 +- .../Validation/ValidationAccumulator.php | 17 + src/docs/data-objects.md | 68 +- src/docs/porting-from-laravel.md | 5 +- tests/Data/Casts/BuiltinTypeCastTest.php | 29 +- .../Data/Casts/DateTimeInterfaceCastTest.php | 192 +- tests/Data/Casts/EnumCastTest.php | 70 +- tests/Data/CreationTest.php | 2102 +++++++++++++++++ .../AbstractPropertyMorphableData.php | 34 + .../Fixtures/Casts/ConfidentialDataCast.php | 21 + .../Data/Fixtures/Casts/MeaningOfLifeCast.php | 29 + .../Data/Fixtures/Casts/StringToUpperCast.php | 37 + .../Fixtures/Collections/CustomCollection.php | 11 + tests/Data/Fixtures/ComplicatedData.php | 43 + tests/Data/Fixtures/DataWithMapper.php | 21 + tests/Data/Fixtures/EnumData.php | 19 + tests/Data/Fixtures/Enums/DummyBackedEnum.php | 11 + .../Fixtures/Enums/PropertyMorphableEnum.php | 11 + .../Fixtures/Factories/FakeModelFactory.php | 25 + .../Factories/FakeNestedModelFactory.php | 27 + tests/Data/Fixtures/FakeModelData.php | 29 + tests/Data/Fixtures/FakeNestedModelData.php | 38 + .../2022_02_12_145756_create_fake_tables.php | 38 + tests/Data/Fixtures/Models/DummyModel.php | 41 + tests/Data/Fixtures/Models/FakeModel.php | 111 + .../Data/Fixtures/Models/FakeNestedModel.php | 37 + tests/Data/Fixtures/MultiData.php | 19 + .../Fixtures/MultiNamespacePhpDocTypes.php | 9 + tests/Data/Fixtures/NestedData.php | 18 + tests/Data/Fixtures/NestedLazyData.php | 27 + .../Data/Fixtures/PropertyMorphableDataA.php | 21 + .../Data/Fixtures/PropertyMorphableDataB.php | 19 + tests/Data/Fixtures/SimpleData.php | 26 + .../Fixtures/SimpleDataWithPropertyHooks.php | 30 + .../Normalized/NormalizedModelTest.php | 67 +- .../Creation/ConstructionStateTest.php | 31 + .../Data/Support/Creation/DataCollectTest.php | 16 + .../Data/Support/Creation/DataCreatorTest.php | 1090 +++++++-- .../Support/Creation/DataInstantiatorTest.php | 84 + .../Support/Creation/SourceResolverTest.php | 31 +- tests/Data/Support/DataClassTest.php | 148 +- tests/Data/Support/DataPropertyTest.php | 365 ++- tests/Data/Support/DataTypeFactoryTest.php | 32 + .../Support/PhpDocTypeNameResolverTest.php | 22 +- .../TransformationContextFactoryTest.php | 92 + .../Validation/CompiledValidationTest.php | 11 +- .../Support/Validation/DataValidatorTest.php | 176 +- 70 files changed, 6320 insertions(+), 754 deletions(-) create mode 100644 src/data/src/Support/Creation/CreationExtensions.php create mode 100644 tests/Data/CreationTest.php create mode 100644 tests/Data/Fixtures/AbstractPropertyMorphableData.php create mode 100644 tests/Data/Fixtures/Casts/ConfidentialDataCast.php create mode 100644 tests/Data/Fixtures/Casts/MeaningOfLifeCast.php create mode 100644 tests/Data/Fixtures/Casts/StringToUpperCast.php create mode 100644 tests/Data/Fixtures/Collections/CustomCollection.php create mode 100644 tests/Data/Fixtures/ComplicatedData.php create mode 100644 tests/Data/Fixtures/DataWithMapper.php create mode 100644 tests/Data/Fixtures/EnumData.php create mode 100644 tests/Data/Fixtures/Enums/DummyBackedEnum.php create mode 100644 tests/Data/Fixtures/Enums/PropertyMorphableEnum.php create mode 100644 tests/Data/Fixtures/Factories/FakeModelFactory.php create mode 100644 tests/Data/Fixtures/Factories/FakeNestedModelFactory.php create mode 100644 tests/Data/Fixtures/FakeModelData.php create mode 100644 tests/Data/Fixtures/FakeNestedModelData.php create mode 100644 tests/Data/Fixtures/Migrations/2022_02_12_145756_create_fake_tables.php create mode 100644 tests/Data/Fixtures/Models/DummyModel.php create mode 100644 tests/Data/Fixtures/Models/FakeModel.php create mode 100644 tests/Data/Fixtures/Models/FakeNestedModel.php create mode 100644 tests/Data/Fixtures/MultiData.php create mode 100644 tests/Data/Fixtures/NestedData.php create mode 100644 tests/Data/Fixtures/NestedLazyData.php create mode 100644 tests/Data/Fixtures/PropertyMorphableDataA.php create mode 100644 tests/Data/Fixtures/PropertyMorphableDataB.php create mode 100644 tests/Data/Fixtures/SimpleData.php create mode 100644 tests/Data/Fixtures/SimpleDataWithPropertyHooks.php diff --git a/docs/upstream-sync/sync.yaml b/docs/upstream-sync/sync.yaml index f73c9f4437..ab1a137fab 100644 --- a/docs/upstream-sync/sync.yaml +++ b/docs/upstream-sync/sync.yaml @@ -156,7 +156,7 @@ spatie/laravel-data: checked_through: ce296f22861dc1237ce468754cc7f46d3ac34ad5 last_reviewed_pr: 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. + 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/data/README.md b/src/data/README.md index bdce65ba49..d406646f98 100644 --- a/src/data/README.md +++ b/src/data/README.md @@ -4,17 +4,21 @@ Documentation: https://hypervel.org/docs/data-objects ## Differences From Laravel -`Resource` authorizes and validates request input under the default `OnlyRequests` strategy, as `Data` and `Dto` do; 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. +`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. -With multiple payloads, Hypervel validates the combined input once rather than a request payload on its own. Later payloads take precedence, and a later explicit `null` replaces an earlier value where Spatie keeps the earlier value. An `Optional` value never replaces an earlier value. +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. -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 built-in normalizer and rule inferrer classes are not included. Typed iterables are always cast and transformed, as with Spatie's `cast_and_transform_iterables` feature, which is not a configuration option. +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 built-in normalizer and rule inferrer classes are not 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. -Responses use the `200` status code for every request method, while Spatie uses `201` for `POST` requests and provides a `calculateResponseStatus()` override. Set a different status in `withResponse()`. +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. Uniform nested collections use wildcard validation rules, while collections with different item shapes or rules use exact indexed rules. +Hypervel rejects data classes with conflicting input or output mappings when the class is first used, rather than letting one value silently overwrite another. 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. -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`. 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. +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. + +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. 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). @@ -22,6 +26,8 @@ Spatie's `ContextableData` and `getDataContext()` are not included. An object's 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 [casts and transformers](https://hypervel.org/docs/data-objects#casts-and-transformers). +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. 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. diff --git a/src/data/src/Concerns/BaseData.php b/src/data/src/Concerns/BaseData.php index 9b9cd1137a..27c8e77a79 100644 --- a/src/data/src/Concerns/BaseData.php +++ b/src/data/src/Concerns/BaseData.php @@ -67,7 +67,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 diff --git a/src/data/src/Contracts/BaseData.php b/src/data/src/Contracts/BaseData.php index 892fc0cc8c..a59d72f5f9 100644 --- a/src/data/src/Contracts/BaseData.php +++ b/src/data/src/Contracts/BaseData.php @@ -48,7 +48,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 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/InvalidDataDeclaration.php b/src/data/src/Exceptions/InvalidDataDeclaration.php index d52bb9224c..763a2e88af 100644 --- a/src/data/src/Exceptions/InvalidDataDeclaration.php +++ b/src/data/src/Exceptions/InvalidDataDeclaration.php @@ -12,39 +12,6 @@ class InvalidDataDeclaration extends LogicException { - /** - * Create an exception for a non-public promoted property. - * - * @param class-string $class - */ - public static function nonPublicPromotedProperty( - string $class, - DataParameter $parameter, - ): self { - $declaringClass = $parameter->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/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/Support/Creation/ConstructionState.php b/src/data/src/Support/Creation/ConstructionState.php index 0b92e59b33..2c65801502 100644 --- a/src/data/src/Support/Creation/ConstructionState.php +++ b/src/data/src/Support/Creation/ConstructionState.php @@ -7,6 +7,10 @@ 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; @@ -25,13 +29,25 @@ final class ConstructionState */ 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}, * uniform?: false, * items?: array * } @@ -139,6 +155,14 @@ public function writeNodeInput(array $input): void $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. * @@ -385,7 +409,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']); if ($this->pathContainsItem()) { $this->markEnclosingCollectionsNonUniform(); @@ -474,6 +498,121 @@ 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 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. */ @@ -757,6 +896,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]; } @@ -933,8 +1076,10 @@ private function pathContainsItem(): bool * class: null|class-string, * mappings: array, * children: array, + * selectedTypes?: array, * autoLazy?: array, * paginatorSource?: AbstractCursorPaginator|AbstractPaginator, + * namedFactory?: array{DataClass, DataMethod, DataMethodMatch}, * uniform?: false, * items?: array * } diff --git a/src/data/src/Support/Creation/CreationContextFactory.php b/src/data/src/Support/Creation/CreationContextFactory.php index e583691968..d360ab4c55 100644 --- a/src/data/src/Support/Creation/CreationContextFactory.php +++ b/src/data/src/Support/Creation/CreationContextFactory.php @@ -404,7 +404,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/DataCreator.php b/src/data/src/Support/Creation/DataCreator.php index 49efa93a56..5255e692e0 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,6 +88,7 @@ public function __construct( protected readonly DataInstantiator $instantiator, protected readonly DataValidator $validator, ) { + $this->emptyExtensions = new CreationExtensions($container, $config); } /** @@ -170,7 +182,7 @@ public function resolveAutoLazyProperty( ): 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); @@ -201,7 +213,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 */ @@ -217,7 +229,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( @@ -324,11 +339,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, @@ -346,14 +361,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); @@ -457,7 +471,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); @@ -541,13 +555,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) { @@ -583,13 +596,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 = []; @@ -636,7 +648,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 @@ -710,17 +722,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) { @@ -756,24 +789,36 @@ protected function fillNode( * Fill one post-factory value through the general construction path. * * @param array $payloads - * @param OperationMemo $extensions + * @return bool false when input beneath a cast-owned value 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 = []; foreach ($payloads as $payload) { - $source = SourceResolver::resolve($class, $payload, $normalizers); + $source = SourceResolver::resolve($payload, $normalizers); + + if ($source === null) { + // Input only the cast or a named factory could read stays as given, so validation reports it. + if ($state->executionDeferred()) { + $state->writeNodeValue($payload); + + return false; + } + + throw CannotCreateData::noNormalizerFound($class, $payload); + } + $sources[] = $source; $unknownInputSources[] = $payload instanceof Request ? ($payload->isJson() ? $payload->json()->all() : $payload->request->all()) @@ -819,6 +864,11 @@ protected function fillGeneralNode( ); } + // 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, @@ -830,6 +880,8 @@ protected function fillGeneralNode( $compilesRules, false, ); + + return true; } /** @@ -839,13 +891,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 { @@ -896,7 +947,8 @@ 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; } @@ -972,28 +1024,39 @@ protected function tryCreateDirectArrayNode( * 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) { + // Beneath a cast-owned value, input only the cast or a named factory could read stays as given. + if ($deferred) { + $state->writeNodeValue($payload); - if ($payload instanceof $class) { - return $payload; + return null; } + + throw CannotCreateData::noNormalizerFound($class, $payload); } - $source = SourceResolver::resolve($class, $payload, []); + if ($match !== null) { + $state->recordNamedFactory($dataClass, ...$match); + } $resolvedProperties = $this->resolveProperties($dataClass, [$source], $state->context); $class = $this->resolveMorphClass($dataClass, $resolvedProperties); @@ -1007,6 +1070,7 @@ protected function fillHookNode( } $state->setNodeClass($class); + $this->fillConstructorInputs($dataClass, [$source], $state); $this->fillResolvedProperties( $dataClass, $resolvedProperties, @@ -1022,13 +1086,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, @@ -1036,7 +1118,7 @@ protected function fillResolvedProperties( array $sources, array $payloads, ConstructionState $state, - array &$extensions, + CreationExtensions $extensions, bool $shouldValidate, bool $compilesRules, bool $fromValidationHook, @@ -1066,7 +1148,7 @@ protected function fillResolvedProperties( if ($value instanceof UnknownProperty) { if ($property->autoLazy !== null - && $this->requiresAutoLazyReplay($property) + && $this->requiresAutoLazyReplay($property, $state->context, $extensions) && ($property->hasDefaultValue || $this->isAutoWhenLoaded($property)) ) { $state->recordAutoLazy( @@ -1097,7 +1179,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 @@ -1130,7 +1212,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( @@ -1138,15 +1219,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( @@ -1158,6 +1271,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); @@ -1273,13 +1396,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, @@ -1329,14 +1451,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 { @@ -1412,14 +1533,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 { @@ -1439,6 +1559,7 @@ protected function reconcileNode( : $this->resolveProperties($dataClass, [$payload], $state->context); $state->resetNodeStructure(); $state->setNodeClass($class); + $this->rematchDeferredFactory($declaredDataClass, $payload, $state); $this->fillResolvedProperties( $dataClass, $resolvedProperties, @@ -1454,6 +1575,7 @@ protected function reconcileNode( return; } + $this->rematchDeferredFactory($declaredDataClass, $payload, $state); $dataClass = $this->dataClasses->get($class); $resolvedProperties = $class === $declaredClass ? $declaredProperties @@ -1500,9 +1622,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, @@ -1511,10 +1652,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; @@ -1528,14 +1696,22 @@ protected function reconcileProperty( $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))) @@ -1566,6 +1742,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, @@ -1650,7 +1837,6 @@ protected function reconcileProperty( /** * Reconcile one changed typed data iterable. * - * @param OperationMemo $extensions * @param non-empty-list $inputPath */ protected function reconcileDataIterable( @@ -1660,7 +1846,7 @@ protected function reconcileDataIterable( mixed $value, array $inputPath, ConstructionState $state, - array &$extensions, + CreationExtensions $extensions, bool $shouldValidate, bool $compilesRules, ): void { @@ -1767,13 +1953,18 @@ 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); $properties = []; @@ -1829,7 +2020,8 @@ protected function castAndInstantiateNode( continue; } - if ($property->hasDefaultValue) { + // Absent properties outside the constructor are resolved after construction, keeping any value it assigned. + if ($property->hasDefaultValue || ! $property->isConstructorParameter) { continue; } @@ -1863,6 +2055,14 @@ protected function castAndInstantiateNode( } } + foreach ($dataClass->constructorInputs as $input) { + $value = $state->read([$input]); + + if (! $value instanceof UnknownProperty) { + $properties[$input] = $value; + } + } + foreach ($state->context->beforeCreationHooks as $hook) { $properties = $hook($properties); } @@ -1882,14 +2082,12 @@ 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 { @@ -1927,7 +2125,7 @@ protected function buildAutoLazy( ); }; - return $this->resolveAutoLazy($property, $extensions)->build( + return $extensions->autoLazy($property)->build( $castValue, $recipe['source'], $property, @@ -1937,14 +2135,12 @@ 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 { @@ -1956,13 +2152,21 @@ 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 !== []) { @@ -1981,8 +2185,6 @@ protected function castProperty( return $this->castDataIterable($property, $dataIterable, $value, $state, $extensions); } - $iterable = $property->type->getNonDataIterableType(); - if ($iterable !== null) { return $this->castTypedIterable( $property, @@ -1996,36 +2198,63 @@ protected function castProperty( ); } - 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 = []; - foreach ($dataCollectableTypes as $type) { - $candidates[] = "{$type->name}<{$type->dataClass}>"; - } + if ($property->type->hasContainerUnion) { + $candidates = []; - 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(); @@ -2047,9 +2276,7 @@ protected function castProperty( continue; } - $key = 'castable:' . $type->name; - /** @var CastableCast $cast */ - $cast = $extensions[$key] ??= new CastableCast($type->name); + $cast = $extensions->castableCast($type->name); $castInputs ??= $this->castInputs($state, $properties); $casted = $cast->cast($property, $value, $castInputs, $state->context); @@ -2061,12 +2288,8 @@ 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); - // The fixed built-in casts ignore sibling values, so none are built for them. - return $cast->cast( + return $extensions->dateCast($dateType)->cast( $property, $value, [], @@ -2077,11 +2300,7 @@ 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, [], @@ -2092,11 +2311,7 @@ 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, [], @@ -2109,15 +2324,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; @@ -2127,7 +2340,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, @@ -2185,7 +2398,6 @@ 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 @@ -2195,11 +2407,14 @@ protected function castTypedIterable( 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)) @@ -2213,7 +2428,7 @@ protected function castTypedIterable( && ! $type->kind->isPaginator() && ! $type->kind->isCursorPaginator() ) { - return $value->map(function (mixed $item) use ($property, $type, $state, &$extensions, $casts, $itemInputs): mixed { + return $value->map(function (mixed $item) use ($property, $type, $state, $extensions, $casts, $itemInputs): mixed { return $this->castIterableItem( $property, $type->iterableItemType, @@ -2258,7 +2473,6 @@ protected function castTypedIterable( /** * Cast one declared iterable item. * - * @param OperationMemo $extensions * @param list $casts * @param array $castInputs */ @@ -2267,7 +2481,7 @@ protected function castIterableItem( Type $type, mixed $value, ConstructionState $state, - array &$extensions, + CreationExtensions $extensions, array $casts, array $castInputs, ): mixed { @@ -2288,7 +2502,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) { @@ -2296,10 +2511,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, $castInputs, $state->context); + $casted = $extensions->castableCast($namedType->name)->cast($property, $value, $castInputs, $state->context); if (! $casted instanceof Uncastable) { return $casted; @@ -2309,11 +2521,7 @@ 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, [], @@ -2324,11 +2532,7 @@ 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, [], @@ -2339,11 +2543,7 @@ 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, [], @@ -2354,26 +2554,6 @@ protected function castIterableItem( return $value; } - /** - * Resolve one automatic lazy attribute for the current root operation. - * - * @param OperationMemo $extensions - */ - protected function resolveAutoLazy(DataProperty $property, array &$extensions): AutoLazy - { - $attribute = $property->autoLazy; - $key = 'auto-lazy:' . spl_object_id($attribute); - - if (! isset($extensions[$key])) { - $extensions[$key] = $attribute->newInstance(); - } - - /** @var AutoLazy $autoLazy */ - $autoLazy = $extensions[$key]; - - return $autoLazy; - } - /** * Build the declared property values a user cast receives for the current node. * @@ -2420,18 +2600,22 @@ protected function castInputs(ConstructionState $state, array $properties): arra /** * Determine if casting a property can reach a user cast that receives the declared values. - * - * @param OperationMemo $extensions */ protected function requiresCastInputs( DataProperty $property, CreationContext $context, - array &$extensions, + CreationExtensions $extensions, ): bool { - if ($this->propertyCasts($property, $context, $extensions) !== []) { + if ($extensions->propertyCasts($property, $context) !== []) { return true; } + foreach ($property->type->getIterableTypes() as $type) { + if (! $type->kind->isDataCollectable() && $extensions->itemCasts($property, $type, $context) !== []) { + return true; + } + } + foreach ($property->type->getNamedTypes() as $type) { if ($type->isCastable || ($type->iterableItemType !== null && $this->hasCastableType($type->iterableItemType)) @@ -2458,118 +2642,16 @@ protected function hasCastableType(Type $type): bool } /** - * Get the ordered custom casts applicable to a property. - * - * @param OperationMemo $extensions - * @return list - */ - protected function propertyCasts( - DataProperty $property, - CreationContext $context, - array &$extensions, - ): array { - $casts = []; - - if ($property->cast !== null) { - $key = 'attribute-cast:' . spl_object_id($property->cast); - - if (! isset($extensions[$key])) { - /** @var GetsCast $attribute */ - $attribute = $property->cast->newInstance(); - $extensions[$key] = $attribute->get(); - } - - $casts[] = $extensions[$key]; - } - - foreach ($context->casts as $baseType => $cast) { - if ($property->type->findAcceptedTypeForBaseType($baseType) !== null) { - $casts[] = $this->resolveCast($cast, $extensions); - } - } - - foreach ($property->configuredCasts as $cast) { - $casts[] = $this->resolveCast($cast, $extensions); - } - - return $casts; - } - - /** - * Resolve one cast once for the current root operation. + * Determine if casts own the construction of a data object or collection property. * - * @param Cast|class-string $cast - * @param OperationMemo $extensions + * 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 resolveCast(Cast|string $cast, array &$extensions): Cast + protected function isCastOwned(DataProperty $property, CreationContext $context, CreationExtensions $extensions): bool { - if ($cast instanceof Cast) { - return $cast; - } - - $key = 'cast:' . $cast; - - /** @var Cast */ - return $extensions[$key] ??= $this->container->make($cast); - } - - /** - * Resolve the custom normalizers for one data class. - * - * @param OperationMemo $extensions - * @return list - */ - 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); + return $property->type->isDataRelated + && ($property->cast !== null || $property->configuredCasts !== [] || $context->casts !== []) + && $extensions->propertyCasts($property, $context) !== []; } /** @@ -2901,7 +2983,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. * @@ -2913,17 +2995,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); @@ -2976,6 +3051,23 @@ 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. * @@ -3062,18 +3154,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; } /** @@ -3085,6 +3189,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..8b7849b50a 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 @@ -85,15 +86,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) { + $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/SourceResolver.php b/src/data/src/Support/Creation/SourceResolver.php index 25307a4fb2..70c5da3b9f 100644 --- a/src/data/src/Support/Creation/SourceResolver.php +++ b/src/data/src/Support/Creation/SourceResolver.php @@ -5,7 +5,6 @@ namespace Hypervel\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\Normalizer; @@ -17,16 +16,12 @@ class SourceResolver { /** - * Resolve a value into one source consumed by the fill walk. + * Resolve a value into one source consumed by the fill walk, or null when nothing can read it. * - * @param class-string $dataClass * @param list $normalizers */ - public static function resolve( - string $dataClass, - mixed $value, - array $normalizers, - ): array|Normalized { + public static function resolve(mixed $value, array $normalizers): array|Normalized|null + { if ($value === null) { return []; } @@ -74,6 +69,6 @@ public static function resolve( } } - throw CannotCreateData::noNormalizerFound($dataClass, $value); + return null; } } diff --git a/src/data/src/Support/DataClass.php b/src/data/src/Support/DataClass.php index a83f05d776..0e8f891894 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 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..bedc92d9c3 100644 --- a/src/data/src/Support/DataClassRepository.php +++ b/src/data/src/Support/DataClassRepository.php @@ -114,8 +114,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/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 c75bf8f482..7269e9a074 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, @@ -163,6 +164,7 @@ public function build(ReflectionClass $reflectionClass): DataClass $reflectionClass, $constructor, $contextualParameters, + $constructorInputs, $properties, ), attributes: $attributes, @@ -234,28 +236,25 @@ 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 and promoted non-public parameters. * - * @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 && ! isset($properties[$parameter->name])) { + $inputs[] = $parameter->name; } } + + return $inputs; } /** @@ -334,7 +333,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); } @@ -558,12 +562,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, @@ -576,7 +582,7 @@ protected function resolveCreationRecipe( return null; } - if ($contextualParameters !== []) { + if ($contextualParameters !== [] || $constructorInputs !== []) { return null; } @@ -599,17 +605,20 @@ protected function resolveCreationRecipe( * * @param ReflectionClass $reflectionClass * @param array $contextualParameters + * @param list $constructorInputs * @param array $properties */ protected function supportsDirectConstructorInstantiation( ReflectionClass $reflectionClass, ?ReflectionMethod $constructor, array $contextualParameters, + array $constructorInputs, array $properties, ): bool { if ($reflectionClass->isAbstract() || ($constructor !== null && (! $constructor->isPublic() || $constructor->isVariadic())) - || $contextualParameters !== []) { + || $contextualParameters !== [] + || $constructorInputs !== []) { return false; } diff --git a/src/data/src/Support/Factories/DataPropertyFactory.php b/src/data/src/Support/Factories/DataPropertyFactory.php index 6b07a9a41d..7a9706114f 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); @@ -134,6 +149,7 @@ className: $reflectionProperty->class, : (is_int($inputMappedName) ? [$inputMappedName] : explode('.', $inputMappedName)), outputMappedName: $outputMappedName, configuredCasts: $configuredCasts, + configuredItemCasts: $configuredItemCasts, configuredTransformers: $configuredTransformers, attributes: $attributes, reflection: $reflectionProperty, @@ -274,14 +290,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..4d8455e9c2 100644 --- a/src/data/src/Support/Factories/DataTypeFactory.php +++ b/src/data/src/Support/Factories/DataTypeFactory.php @@ -372,6 +372,7 @@ protected function matchingAnnotation( array $annotations, ): ?DataIterableAnnotation { $fallback = null; + $shorthandFallback = null; foreach ($annotations as $annotation) { $container = $this->resolvePhpDocName( @@ -391,10 +392,17 @@ protected function matchingAnnotation( ) ) { $fallback = $annotation; + + continue; + } + + // Item shorthands such as `Item[]` or `array` describe any container, as in upstream. + if ($shorthandFallback === null && ($container === 'array' || $container === 'iterable')) { + $shorthandFallback = $annotation; } } - return $fallback; + return $fallback ?? $shorthandFallback; } /** 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 1dab5290c7..6ea5df42e2 100644 --- a/src/data/src/Support/Validation/DataValidationCompiler.php +++ b/src/data/src/Support/Validation/DataValidationCompiler.php @@ -91,7 +91,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, ); @@ -130,12 +130,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. * @@ -155,6 +201,15 @@ protected function compileNode( $dataClass = $this->dataClasses->get($class); $contextualProperties = $dataClass->contextualParameters; + 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) { continue; @@ -205,9 +260,9 @@ 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( @@ -216,7 +271,7 @@ protected function compileNode( $propertyPath, $value, $state, - $nestedDataClass !== null || $dataIterable !== null, + $this->inferredTypeRule($property, $hasValue, $value, $dataType), $inferredRequired, ); @@ -506,6 +561,7 @@ protected function compileUniformDynamicIterable( /** * Infer fixed presence and type rules for one property. * + * @param null|'array'|'boolean'|'integer'|'numeric'|'string' $typeRule * @return list */ protected function propertyRules( @@ -514,7 +570,7 @@ protected function propertyRules( ValidationPath $propertyPath, mixed $value, ConstructionState $state, - bool $expectsArray, + ?string $typeRule, bool &$inferredRequired, ): array { $attributes = []; @@ -550,7 +606,7 @@ protected function propertyRules( } } - $inferredRules = $this->inferRules($property, $expectsArray, $hasPresenceRule); + $inferredRules = $this->inferRules($property, $typeRule, $hasPresenceRule); $propertyRules = $this->applyRuleInferrers($property, $attributes, $inferredRules, $nodePath, $state); // An inferrer may remove the inferred requirement; class rules must not treat it as present. $inferredRequired = in_array('required', $inferredRules, true) @@ -582,7 +638,7 @@ protected function propertyRules( $this->ruleDenormalizer->execute($generatedRules, $nodePath), ); } else { - $generatedRules = $this->inferRules($property, $expectsArray, $hasPresenceRule); + $generatedRules = $this->inferRules($property, $typeRule, $hasPresenceRule); $inferredRequired = in_array('required', $generatedRules, true); $rules = $this->mergeRules($attributeRules, $generatedRules); } @@ -601,11 +657,12 @@ protected function propertyRules( /** * Infer fixed presence and type rules for one property. * + * @param null|'array'|'boolean'|'integer'|'numeric'|'string' $typeRule * @return list<'array'|'boolean'|'integer'|'nullable'|'numeric'|'required'|'sometimes'|'string'> */ protected function inferRules( DataProperty $property, - bool $expectsArray, + ?string $typeRule, bool $hasPresenceRule = false, ): array { $rules = match (true) { @@ -615,8 +672,6 @@ protected function inferRules( default => [], }; - $typeRule = $expectsArray ? 'array' : $this->primitiveRule($property); - if ($typeRule !== null) { $rules[] = $typeRule; } @@ -1005,9 +1060,18 @@ protected function translateRuleSegments( )]; } - $nestedDataClass = $property->type->getDataObjectClass(); + $dataType = $this->receivingDataType($property, $state, $hasValue, $value); - if ($nestedDataClass !== null) { + if ($dataType === null) { + return [new TranslatedValidationPath( + $this->appendUnmappedSegments($path, $segments, $offset + 1), + $this->appendUnmappedSegments($structuralPath, $segments, $offset + 1), + )]; + } + + if ($dataType->kind->isDataObject()) { + /** @var class-string $nestedDataClass */ + $nestedDataClass = $dataType->dataClass; $state->enterProperty($property->name, $inputPath); try { @@ -1025,17 +1089,8 @@ protected function translateRuleSegments( } } - $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); @@ -1328,14 +1383,7 @@ protected function primitiveRule(DataProperty $property): ?string $rules = []; foreach ($property->type->getNamedTypes() as $type) { - $rule = match ($type->name) { - 'array', 'iterable' => 'array', - 'bool', 'false', 'true' => 'boolean', - 'float' => 'numeric', - 'int' => 'integer', - 'string' => 'string', - default => null, - }; + $rule = $this->typeRuleFor($type); if ($rule !== null) { $rules[$rule] = true; @@ -1345,6 +1393,55 @@ protected function primitiveRule(DataProperty $property): ?string return count($rules) === 1 ? array_key_first($rules) : null; } + /** + * Get the type rule for one declared named type. + * + * @return null|'array'|'boolean'|'integer'|'numeric'|'string' + */ + protected function typeRuleFor(NamedType $type): ?string + { + return match ($type->name) { + 'array', 'iterable' => 'array', + 'bool', 'false', 'true' => 'boolean', + 'float' => 'numeric', + 'int' => 'integer', + 'string' => 'string', + default => 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' + */ + protected function inferredTypeRule( + DataProperty $property, + bool $hasValue, + mixed $value, + ?NamedType $dataType, + ): ?string { + 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); + } + /** * Get the wire key selected during Fill or its canonical fallback. */ @@ -1362,6 +1459,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..c622479df2 100644 --- a/src/data/src/Support/Validation/DataValidator.php +++ b/src/data/src/Support/Validation/DataValidator.php @@ -163,7 +163,7 @@ public function validate( $payload = $this->restoreSourceKeyOrder( $compiled->restorePreservedValues( $validator->validate(), - $sourcePayload, + $validator->getData(), ), $sourcePayload, ); diff --git a/src/data/src/Support/Validation/ValidationAccumulator.php b/src/data/src/Support/Validation/ValidationAccumulator.php index f42cd65fa9..136057fec8 100644 --- a/src/data/src/Support/Validation/ValidationAccumulator.php +++ b/src/data/src/Support/Validation/ValidationAccumulator.php @@ -25,6 +25,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 +65,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 +94,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; diff --git a/src/docs/data-objects.md b/src/docs/data-objects.md index 9c8c496f4f..e62ec483c7 100644 --- a/src/docs/data-objects.md +++ b/src/docs/data-objects.md @@ -154,7 +154,7 @@ class UserData extends Data } ``` -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. +Constructor-promoted `readonly` properties are supported, including properties promoted by a parent constructor that your constructor calls. Public properties that are not declared by a constructor are assigned after construction and therefore cannot be `readonly`, unless the class sets the value itself as a `#[Computed]` property. ```php term = trim($query); + } +} + +SearchData::from(['query' => ' laravel ']); +``` + ### Creating Instances @@ -191,6 +210,8 @@ $user = UserData::from([ `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 @@ -251,6 +272,8 @@ 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. +A public property declared outside the constructor keeps any value the constructor assigned to it when its input is missing or `Optional`; supplied input, including `null`, replaces it. Validation infers its 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 @@ -306,7 +329,7 @@ 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. This also applies to requests, so a named method that returns the finished object from a request is responsible for validating it. The data class's authorization still runs first. 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, and Hypervel uses it directly without validating, casting, or running creation hooks for it again. This also applies to requests, so a named method that returns the finished object from a request is responsible for validating it. The data class's authorization still runs first. To convert other input into the shape Hypervel reads, use a [normalizer](#casts-and-transformers) or [`prepareForPipeline()`](#preparing-input) instead. 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. @@ -422,7 +445,34 @@ $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 @@ -617,7 +667,9 @@ class InvoiceData extends Data A cast implements `Hypervel\Data\Casts\Cast`; a transformer implements `Hypervel\Data\Transformers\Transformer`. Return `Uncastable::create()` from a cast when the next applicable candidate should be tried. Returning `null` means the cast produced a real null value. -A cast's `cast` method receives the property, its input value, the object's declared property values keyed by property name, and the `CreationContext`. Undeclared input, contextual constructor values, and computed properties are not included. Properties that come earlier in the class have already been cast. Later properties contain their supplied input, or the default, `Optional`, or `null` value the object will receive when their input is absent: +A cast on a nested data object or data collection property receives the input before Hypervel builds any nested objects, so it may build them itself. Without validation, the cast receives the value exactly as it was given. When the object is validated, the value is first prepared and checked against the rules of its declared types, as it would be without the cast: models, objects, and collections become the arrays they would be built from, and the cast receives the validated arrays. A value that only the cast or a named method could interpret, such as a string for a nested data object, therefore fails validation. When every cast returns `Uncastable`, Hypervel builds the nested objects from the validated input. Named methods and constructors beneath the cast run only then, and a named method still receives the value it matched. + +A cast's `cast` method receives the property, its input value, the object's declared property values keyed by property name, and the `CreationContext`. Undeclared input, contextual constructor values, and computed properties are not included. Properties that come earlier in the class have already been cast. Later properties contain their supplied input, or the default, `Optional`, or `null` value the object will receive when their input is absent. A value the constructor will assign to a property outside the constructor is not known yet: ```php use Hypervel\Data\Casts\Cast; @@ -633,7 +685,7 @@ class MoneyCast implements Cast } ``` -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. +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. A cast registered for a type in a factory or the configuration also casts the items of iterables declared with that item type when it implements `IterableItemCast`. 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. @@ -798,6 +850,8 @@ Factories may change the validation strategy, enable or disable name mapping and The `prepareData`, `beforeCreation`, and `afterCreation` hooks run even when validation is skipped. The other hooks run while generating rules or validating, as appropriate. The `beforeValidation` hook receives the complete input and may add fields for the rules to read, while `afterValidation` receives the validated payload. Call `alwaysValidate()` when validation hooks should also apply to an array, model, JSON value, or another non-request source. +The `beforeCreation` hook 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. @@ -848,7 +902,7 @@ return $user->include('profile')->toArray(); 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 @@ -901,6 +955,8 @@ $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. +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. 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: diff --git a/src/docs/porting-from-laravel.md b/src/docs/porting-from-laravel.md index eefe334f9f..ce858b46c8 100644 --- a/src/docs/porting-from-laravel.md +++ b/src/docs/porting-from-laravel.md @@ -576,7 +576,7 @@ When porting `spatie/laravel-data`, replace its namespace with `Hypervel\Data` a 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. -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. Typed iterables are always cast and transformed. +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. 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: @@ -586,7 +586,10 @@ Review these behavior differences in ported code: - 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. +- 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`. - Replace `getDataContext()` with `getPartialsDefinition()` and `getWrap()`, and `make:data --namespace` with `--target-namespace` and a complete namespace. diff --git a/tests/Data/Casts/BuiltinTypeCastTest.php b/tests/Data/Casts/BuiltinTypeCastTest.php index 0d8c2e2cf0..6b30a831a4 100644 --- a/tests/Data/Casts/BuiltinTypeCastTest.php +++ b/tests/Data/Casts/BuiltinTypeCastTest.php @@ -13,11 +13,8 @@ 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); $property = $this->createStub(DataProperty::class); @@ -33,14 +30,22 @@ 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']], ]; } } diff --git a/tests/Data/Casts/DateTimeInterfaceCastTest.php b/tests/Data/Casts/DateTimeInterfaceCastTest.php index 9148453683..fa56f2c5b2 100644 --- a/tests/Data/Casts/DateTimeInterfaceCastTest.php +++ b/tests/Data/Casts/DateTimeInterfaceCastTest.php @@ -6,14 +6,20 @@ 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\CreationContext; @@ -25,11 +31,161 @@ 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. */ @@ -127,40 +283,14 @@ public function testTimezoneConversionPreservesExactConcreteTypes(): void } } - /** - * Test iterable date declarations and non-date declarations. - */ - public function testCastsIterableDatesAndDeclinesNonDateProperties(): void + public function testCastsIterableDates(): void { [$properties, $context] = $this->operation(['Y-m-d']); - $cast = new DateTimeInterfaceCast; - $date = $cast->castIterableItem($this->property('dates'), '2026-08-30', $properties, $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', $properties, $context), - ); - } - - /** - * Test invalid dates name the target and accepted formats. - */ - public function testThrowsWhenNoDateFormatMatches(): void - { - [$properties, $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', - $properties, - $context, - ); + $this->assertSame('2026-08-30', $date->format('Y-m-d')); } /** @@ -245,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 5edea66a1b..49ad9b4152 100644 --- a/tests/Data/Casts/EnumCastTest.php +++ b/tests/Data/Casts/EnumCastTest.php @@ -23,10 +23,7 @@ class EnumCastTest extends TestCase { - /** - * Test declared backed enum values are cast or passed through. - */ - public function testCastsDeclaredBackedEnumValues(): void + public function testCanCastEnum(): void { [$properties, $context] = $this->operation(); $property = $this->property('status'); @@ -37,6 +34,36 @@ public function testCastsDeclaredBackedEnumValues(): void $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), + ); + } + /** * Test integer-backed enums accept numeric strings. */ @@ -68,32 +95,6 @@ public function testCastsIterableBackedEnumValues(): void ); } - /** - * Test a declaration without a backed enum declines the cast. - */ - public function testReturnsUncastableWithoutABackedEnumDeclaration(): void - { - [$properties, $context] = $this->operation(); - - $this->assertSame( - Uncastable::create(), - (new EnumCast)->cast($this->property('name'), 'ready', $properties, $context), - ); - } - - /** - * Test invalid enum values produce a property-specific exception. - */ - public function testThrowsForAnInvalidBackedEnumValue(): void - { - [$properties, $context] = $this->operation(); - - $this->expectException(CannotCastEnum::class); - $this->expectExceptionMessageIsOrContains('EnumCastDataFixture::$status'); - - (new EnumCast)->cast($this->property('status'), 'invalid', $properties, $context); - } - /** * Build one property definition. */ @@ -144,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/CreationTest.php b/tests/Data/CreationTest.php new file mode 100644 index 0000000000..54cf4f6d79 --- /dev/null +++ b/tests/Data/CreationTest.php @@ -0,0 +1,2102 @@ + $this->getRefreshConnection(), + '--realpath' => true, + '--path' => __DIR__ . '/Fixtures/Migrations', + ]; + } + + public function testCanUseDefaultTypesToCreateDataObjects(): void + { + $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 + { + $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' => DateTime::createFromFormat('d-m-Y', '16-06-1994'), + '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->assertEquals(DateTime::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 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; + }; + + $data = $dataClass::from([ + 'strings' => ['Hello', 42, 3.14, true, '0', 'false'], + 'bools' => ['Hello', 42, 3.14, true, ['nested'], '0', 'false'], + 'ints' => ['Hello', 42, 3.14, true, ['nested'], '0', 'false'], + 'floats' => ['Hello', 42, 3.14, true, ['nested'], '0', 'false'], + 'arrays' => ['Hello', 42, 3.14, true, ['nested'], '0', 'false'], + ]); + + $this->assertSame(['Hello', '42', '3.14', '1', '0', 'false'], $data->strings); + $this->assertSame([true, true, true, true, true, false, false], $data->bools); + $this->assertSame([0, 42, 3, 1, 1, 0, 0], $data->ints); + $this->assertSame([0.0, 42.0, 3.14, 1.0, 1.0, 0.0, 0.0], $data->floats); + $this->assertEquals([['Hello'], [42], [3.14], [true], ['nested'], ['0'], ['false']], $data->arrays); + } + + // 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: withoutOptionalValues(); Optional declarations always preserve absence. + + 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); + } +} diff --git a/tests/Data/Fixtures/AbstractPropertyMorphableData.php b/tests/Data/Fixtures/AbstractPropertyMorphableData.php new file mode 100644 index 0000000000..d4f35e1b6c --- /dev/null +++ b/tests/Data/Fixtures/AbstractPropertyMorphableData.php @@ -0,0 +1,34 @@ + 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/Collections/CustomCollection.php b/tests/Data/Fixtures/Collections/CustomCollection.php new file mode 100644 index 0000000000..b45739f332 --- /dev/null +++ b/tests/Data/Fixtures/Collections/CustomCollection.php @@ -0,0 +1,11 @@ + $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/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..b92062b2ac --- /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->bigInteger('fake_model_id')->nullable(); + $table->foreign('fake_model_id')->references('id')->on('fake_models')->cascadeOnDelete(); + + $table->timestamps(); + }); + } +}; 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/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 @@ + SimpleData::from($string))); + } +} 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 @@ + '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/Normalizers/Normalized/NormalizedModelTest.php b/tests/Data/Normalizers/Normalized/NormalizedModelTest.php index cb6b3cab97..2534df63ae 100644 --- a/tests/Data/Normalizers/Normalized/NormalizedModelTest.php +++ b/tests/Data/Normalizers/Normalized/NormalizedModelTest.php @@ -15,6 +15,7 @@ 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 +24,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 +38,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 +89,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. */ @@ -106,8 +135,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 +153,10 @@ class NormalizedModelDataFixture class NormalizedModelFixture extends Model { + protected array $casts = [ + 'cast_column' => 'string', + ]; + public int $serializationCount = 0; public int $loadMissingCount = 0; @@ -128,6 +169,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/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/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 ef59c6db10..acae5e1ae3 100644 --- a/tests/Data/Support/Creation/DataCreatorTest.php +++ b/tests/Data/Support/Creation/DataCreatorTest.php @@ -19,6 +19,8 @@ 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\Min; use Hypervel\Data\Attributes\WithCast; use Hypervel\Data\Casts\Cast; use Hypervel\Data\Casts\Castable; @@ -52,18 +54,22 @@ 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\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: Configurable pipeline and inherited-context factory tests; use the fixed engine and fresh factory hooks. // REMOVED: withOptionalValues()/withoutOptionalValues() tests; Optional declarations always preserve absence. // REMOVED: Data-specific From* injection tests; Hypervel contextual attributes cover the same outcomes directly. // REMOVED: UnserializeCast tests; serialized request input is not accepted by a built-in cast. @@ -514,19 +520,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. */ @@ -805,13 +798,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 @@ -1084,20 +1070,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 @@ -1114,17 +1098,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(); @@ -1164,48 +1137,6 @@ public function testContextualConstructorValuesOverrideClientPayload(): void $this->assertSame('Server', $data->name); } - public function testResolvesAbstractPropertyMorphsBeforeFillingConcreteProperties(): void - { - $shape = ShapeCreationData::from([ - 'type' => 'circle', - 'radius' => '12', - ]); - - $this->assertInstanceOf(CircleCreationData::class, $shape); - $this->assertSame(12, $shape->radius); - } - - public function testResolvesMorphsFromBackedEnumsDefaultsAndNestedCollections(): void - { - $default = DefaultShapeCreationData::from(['radius' => '3']); - $mapped = DefaultShapeCreationData::from([ - 'status' => 'active', - 'radius' => '4', - ]); - $nested = ShapeListCreationData::from([ - 'shapes' => [ - [ - 'type' => 'circle', - 'radius' => '5', - ], - [ - 'type' => 'square', - 'side' => '6', - ], - ], - ]); - - $this->assertInstanceOf(DefaultCircleCreationData::class, $default); - $this->assertSame(CreationStatus::Active, $default->status); - $this->assertSame(3, $default->radius); - $this->assertInstanceOf(DefaultCircleCreationData::class, $mapped); - $this->assertSame(4, $mapped->radius); - $this->assertInstanceOf(CircleCreationData::class, $nested->shapes[0]); - $this->assertSame(5, $nested->shapes[0]->radius); - $this->assertInstanceOf(SquareCreationData::class, $nested->shapes[1]); - $this->assertSame(6, $nested->shapes[1]->side); - } - public function testResolvesIntegerBackedMorphFromNumericString(): void { $shape = IntegerShapeCreationData::from([ @@ -1291,6 +1222,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); @@ -1299,10 +1238,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]], @@ -1324,17 +1282,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']); @@ -1343,6 +1302,132 @@ public function testExplicitCastOwnsAnAmbiguousDataCollectableUnion(): void $this->assertSame(7, $data->children->first()->id); } + public function testTypedContainerUnionsCastItemsInTheContainerTheValueSelects(): void + { + $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(['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 testRejectsValuesNoSingleContainerTypeAccepts(): void + { + 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()); + } + } + } + + public function testMixedContainerUnionsSelectTheDataOrPlainTypeByContainer(): void + { + $plain = MixedContainerUnionCreationData::from(['children' => ['a' => '1', 'b' => '2']]); + $data = MixedContainerUnionCreationData::from(['children' => new Collection([['id' => '3']])]); + + $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 testCollectionItemsSelectTheirOwnContainerTypes(): void + { + try { + 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())); + } + + $data = MixedContainerUnionListData::validateAndCreate(['items' => [ + ['children' => [1, 2]], + ['children' => new Collection([['id' => '3']])], + ]]); + + $this->assertSame([1, 2], $data->items[0]->children); + $this->assertSame(3, $data->items[1]->children->first()->id); + } + + public function testValidationHooksReselectTheContainerType(): void + { + $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); + } + + public function testAutomaticLazyContainerUnionsKeepTheirSelection(): void + { + $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); + } + + #[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()); + } + public function testUserCastsReceiveTheDeclaredPropertyValues(): void { RecordingInputsCast::$properties = null; @@ -1405,6 +1490,184 @@ public function testDeferredUserCastsSeeTheValuesFromWhenTheyWereDeferred(): voi $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' => ['name' => 'Taylor', 'undeclared' => 'dropped'], + ]); + + $this->assertSame(['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']); @@ -1500,6 +1763,42 @@ public function testConfiguredGlobalNormalizersParticipateInConstruction(): void $this->assertSame('item', $data->label); } + public function testConstructorInputsReceiveRawInputByName(): void + { + $this->assertSame( + ['prefix' => 'later', 'options' => ['a' => '1'], 'secret' => 'none'], + ConstructorInputCreationData::from(['prefix' => 'first', 'options' => ['a' => '1']], ['prefix' => 'later'])->received, + ); + + $hooked = ConstructorInputCreationData::factory() + ->beforeCreation(static fn (array $properties): array => [...$properties, 'prefix' => 'hooked']) + ->from(['secret' => 'kept']); + + $this->assertSame(['prefix' => 'hooked', 'options' => [], 'secret' => 'kept'], $hooked->received); + + $this->expectException(CannotCreateData::class); + $this->expectExceptionMessageIsOrContains('Parameters missing: prefix'); + + ConstructorInputCreationData::from([]); + } + + 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 { @@ -1571,6 +1870,14 @@ 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 @@ -1741,22 +2048,6 @@ public function __construct(public int $id) } } -class DirectNamedFactoryCreationData extends Data -{ - public static int $calls = 0; - - public function __construct(public int $id) - { - } - - public static function fromString(string $value): array - { - ++self::$calls; - - return ['id' => (int) $value]; - } -} - class DirectValidationModeCreationData extends Data { public function __construct(public int $id) @@ -2050,22 +2341,6 @@ public function __construct( } } -class AutoLazyNamedFactoryData extends Data -{ - public static ?AutoLazyNamedFactorySource $source = null; - - public function __construct( - #[RecordingAutoLazy] - public Lazy|string $title, - ) { - } - - public static function fromString(string $title): AutoLazyNamedFactorySource - { - return self::$source = new AutoLazyNamedFactorySource($title); - } -} - class AutoLazyNamedPayloadData extends Data { public function __construct( @@ -2075,14 +2350,6 @@ public function __construct( } } -class AutoLazyNamedFactorySource -{ - public function __construct( - public readonly string $title, - ) { - } -} - #[Attribute(Attribute::TARGET_PROPERTY)] class RecordingAutoLazy extends AutoLazy { @@ -2482,57 +2749,12 @@ public function __construct( public static function morph(array $properties): ?string { return match ($properties['type']) { - 'circle' => CircleCreationData::class, - 'square' => SquareCreationData::class, 'invalid' => ChildCreationData::class, default => null, }; } } -class CircleCreationData extends ShapeCreationData -{ - public function __construct(string $type, public int $radius) - { - parent::__construct($type); - } -} - -class SquareCreationData extends ShapeCreationData -{ - public function __construct(string $type, public int $side) - { - parent::__construct($type); - } -} - -abstract class DefaultShapeCreationData extends Data implements PropertyMorphableData -{ - public function __construct( - #[PropertyForMorph] - public CreationStatus $status = CreationStatus::Active, - ) { - } - - public static function morph(array $properties): ?string - { - return match ($properties['status']) { - CreationStatus::Active => DefaultCircleCreationData::class, - default => null, - }; - } -} - -class DefaultCircleCreationData extends DefaultShapeCreationData -{ - public function __construct( - public int $radius, - CreationStatus $status = CreationStatus::Active, - ) { - parent::__construct($status); - } -} - abstract class IntegerShapeCreationData extends Data implements PropertyMorphableData { public function __construct( @@ -2557,21 +2779,7 @@ public function __construct(IntegerCreationStatus $status, public int $radius) } } -class ShapeListCreationData extends Data -{ - /** - * Create a shape-list fixture. - * - * @param array $shapes - */ - public function __construct( - #[DataCollectionOf(ShapeCreationData::class)] - public array $shapes, - ) { - } -} - -class MappedItemCreationData extends Data +class MappedItemCreationData extends Data { public function __construct( #[MapInputName('profile.name')] @@ -2610,6 +2818,52 @@ public function __construct( } } +class MappedUnionChildCreationData extends Data +{ + /** + * Create a child whose input name is mapped. + */ + public function __construct( + #[MapInputName('external_name')] + public string $name, + ) { + } +} + +class DataObjectUnionCreationData extends Data +{ + /** + * Create a fixture whose unions keep accepted arrays and build a child from other values. + */ + public function __construct( + public array|MappedUnionChildCreationData $child, + public Collection|array|MappedUnionChildCreationData|null $containers = null, + ) { + } + + /** + * Require the child name, which a built child reads from its mapped input name. + */ + public static function rules(): array + { + return ['child.name' => ['required']]; + } +} + +class DataObjectUnionListData extends Data +{ + /** + * Create a fixture whose items each hold a data object union. + * + * @param array $items + */ + public function __construct( + #[DataCollectionOf(DataObjectUnionCreationData::class)] + public array $items, + ) { + } +} + class AmbiguousDataBeforeDateCreationData extends Data { public function __construct( @@ -2710,6 +2964,90 @@ public function __construct( } } +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( + public Collection|array $both, + public Collection|array $shorthand, + public Collection|array $arrayOnly, + ) { + } +} + +class OverlappingContainerUnionCreationData extends Data +{ + /** + * Create a fixture whose container types both accept a collection. + * + * @param Collection|Enumerable $values + */ + public function __construct( + public Collection|Enumerable $values, + ) { + } +} + +class MixedContainerUnionCreationData extends Data +{ + /** + * Create a fixture whose union holds a data collection and a plain array. + * + * @param array|Collection $children + */ + public function __construct( + public Collection|array $children, + ) { + } +} + +class MixedContainerUnionListData extends Data +{ + /** + * Create a fixture whose items each hold a container union. + * + * @param array $items + */ + public function __construct( + #[DataCollectionOf(MixedContainerUnionCreationData::class)] + public array $items, + ) { + } +} + +class AutoLazyContainerUnionCreationData extends Data +{ + /** + * Create a fixture whose automatic lazy container union is resolved later. + * + * @param array|Collection|Lazy $children + */ + public function __construct( + #[AutoLazy] + public Lazy|Collection|array $children, + ) { + } +} + +class ConfiguredItemCastUnionCreationData extends Data +{ + /** + * Create a fixture whose container union holds strings. + * + * @param array|Collection $names + */ + public function __construct( + public Collection|array $names, + ) { + } +} + class AmbiguousDataCollectableCreationCast implements Cast { public function cast( @@ -3048,6 +3386,378 @@ public static function prepareForPipeline(array $properties): array } } +class CastOwnedChildData extends Data +{ + /** + * Create a child whose input name is mapped. + */ + public function __construct( + #[MapInputName('external_name')] + public string $name, + ) { + } + + /** + * 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 CastOwnedChildModel extends Model +{ +} + +class CastOwnedNestingData extends Data +{ + /** + * Create a parent that holds a child. + */ + public function __construct( + public CastOwnedChildData $child, + ) { + } +} + +class CastOwnedWrapperData extends Data +{ + /** + * Create a fixture whose cast owns a parent that holds a child. + */ + public function __construct( + #[WithCast(CastOwnedChildCast::class)] + public CastOwnedNestingData $wrapper, + ) { + } +} + +class CastOwnedAddressData extends Data +{ + /** + * Create an address built from its parts. + */ + public function __construct(public string $address) + { + } + + /** + * Combine the address parts before the address is read. + */ + public static function prepareForPipeline(array $properties): array + { + $properties['address'] ??= "{$properties['line_1']}, {$properties['city']}"; + + return $properties; + } +} + +class CastOwnedInnerData extends Data +{ + /** + * Create a value holding a union child and a prepared child. + */ + public function __construct( + public array|CastOwnedChildData $child, + public ?CastOwnedAddressData $address = 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 CastOwnedLazyChildData extends Data +{ + /** + * Create a child whose relation is included only once it is loaded. + */ + public function __construct( + public string $name, + #[AutoWhenLoadedLazy] + public Lazy|CastOwnedChildData|null $relation, + ) { + } +} + +class CastOwnedLazyParentData extends Data +{ + /** + * Create a fixture whose cast owns a child read from a model. + */ + public function __construct( + #[WithCast(CastOwnedChildCast::class)] + public CastOwnedLazyChildData $child, + ) { + } +} + +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 + { + if (! is_string($value)) { + return null; + } + + ++self::$calls; + + return ['name' => $value]; + } +} + +class NormalizedNameChildData extends Data +{ + /** + * Create a child read from a plain name. + */ + public function __construct(public string $name) + { + } + + /** + * Get the normalizers that read this child's input. + * + * @return list> + */ + public static function normalizers(): array + { + return [CountingNameNormalizer::class]; + } +} + +class CastOwnedNormalizedParentData extends Data +{ + /** + * Create a fixture whose cast owns a child read from a plain name. + */ + public function __construct( + #[WithCast(CastOwnedChildCast::class)] + public NormalizedNameChildData $child, + ) { + } +} + +class PendingFactorySource +{ + /** + * Create a source a named factory reads. + */ + public function __construct(public string $name) + { + } +} + +class PendingFactoryChildData extends Data +{ + public static int $calls = 0; + + /** + * Create a child. + */ + 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 PendingFactoryWrapperData extends Data +{ + /** + * Create a wrapper whose children may come from a named factory. + */ + public function __construct( + public PendingFactoryChildData $child, + #[Exclude] + public ?PendingFactoryChildData $excluded = null, + ) { + } +} + +class AcceptingWrapperCast implements Cast +{ + /** + * 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 PendingFactoryAcceptedData extends Data +{ + /** + * Create a fixture whose cast accepts the wrapper. + */ + public function __construct( + #[WithCast(AcceptingWrapperCast::class)] + public PendingFactoryWrapperData $wrapper, + ) { + } +} + +class PendingFactoryDeclinedData extends Data +{ + /** + * Create a fixture whose cast declines the wrapper. + */ + public function __construct( + #[WithCast(CastOwnedChildCast::class)] + public PendingFactoryWrapperData $wrapper, + ) { + } +} + +class CastOwnedChildCast implements Cast +{ + 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 CastOwnedParentData extends Data +{ + /** + * Create a parent whose child is owned by a cast. + */ + public function __construct( + #[WithCast(CastOwnedChildCast::class)] + public CastOwnedChildData $child, + ) { + } +} + +abstract class CastOwnedShapeData extends Data implements PropertyMorphableData +{ + /** + * Create a property-morphable shape. + */ + public function __construct( + #[PropertyForMorph] + public string $type, + ) { + } + + /** + * Resolve the concrete shape class. + */ + public static function morph(array $properties): ?string + { + return $properties['type'] === 'square' ? CastOwnedSquareData::class : null; + } +} + +class CastOwnedSquareData extends CastOwnedShapeData +{ + /** + * Create a square with a concrete-only rule. + */ + public function __construct( + #[Min(3)] + public string $code, + ) { + parent::__construct('square'); + } +} + +class CastOwnedShapesData extends Data +{ + /** + * Create a fixture with cast-owned polymorphic properties. + * + * @param array $shapes + */ + public function __construct( + #[WithCast(CastOwnedChildCast::class)] + public CastOwnedShapeData $shape, + #[WithCast(CastOwnedChildCast::class), DataCollectionOf(CastOwnedShapeData::class)] + public array $shapes, + ) { + } +} + +class CastOwnedUnionData extends Data +{ + /** + * Create a fixture whose cast-owned values the cast declines. + * + * @param array|CastOwnedChildData $numbers + * @param null|LazyCollection $children + */ + public function __construct( + #[WithCast(CastOwnedChildCast::class)] + public array|CastOwnedChildData $child, + #[WithCast(CastOwnedChildCast::class)] + public array|CastOwnedChildData $numbers, + #[WithCast(CastOwnedChildCast::class)] + public ?LazyCollection $children = null, + ) { + } +} + +class ConstructorInputCreationData extends Data +{ + public array $received; + + /** + * Create a fixture whose constructor parameters have no public data properties. + * + * @param array $options + */ + public function __construct(string $prefix, array $options = [], protected string $secret = 'none') + { + $this->received = ['prefix' => $prefix, 'options' => $options, 'secret' => $secret]; + } +} + +class ConstructorAssignedCreationData extends Data +{ + 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}"; + } +} + class ComputedCreationData extends Data { #[Computed] diff --git a/tests/Data/Support/Creation/DataInstantiatorTest.php b/tests/Data/Support/Creation/DataInstantiatorTest.php index 1419ab5c1c..00421e8698 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. */ @@ -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..e968ad3657 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,8 +35,8 @@ 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->assertSame([], SourceResolver::resolve(null, [$normalizer])); + $this->assertSame($normalized, SourceResolver::resolve($normalized, [$normalizer])); } /** @@ -60,7 +59,7 @@ public function normalize(mixed $value): array|Normalized|null $this->assertSame( ['custom' => 'value'], - SourceResolver::resolve(self::class, ['original' => 'value'], [$skipped, $accepted]), + SourceResolver::resolve(['original' => 'value'], [$skipped, $accepted]), ); } @@ -88,26 +87,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 testReturnsNullWhenNoFixedOrCustomNormalizerAcceptsTheValue(): 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/DataClassTest.php b/tests/Data/Support/DataClassTest.php index aeb9834f5b..41eac3608c 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; @@ -34,6 +36,7 @@ use Hypervel\Data\Support\Annotations\DataIterableAnnotationReader; 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; @@ -51,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. */ @@ -344,11 +425,24 @@ 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 + { + foreach ([ + NonPublicPromotedDataFixture::class => ['secret'], + MissingPropertyDataFixture::class => ['source'], + ] as $class => $inputs) { + $metadata = $this->factory()->build(new ReflectionClass($class)); + + $this->assertSame($inputs, $metadata->constructorInputs); + $this->assertSame([], $metadata->properties); + $this->assertNull($metadata->creationRecipe); + $this->assertFalse($metadata->directConstructorInstantiation); + } + } + /** * Test duplicate effective mapping ownership. * @@ -953,3 +1047,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/DataPropertyTest.php b/tests/Data/Support/DataPropertyTest.php index 5310aa4fb4..52d8f5ba13 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,9 +19,14 @@ 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; @@ -31,14 +40,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. */ @@ -88,7 +323,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, @@ -100,8 +334,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); @@ -190,6 +422,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), + ))->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. * @@ -273,7 +535,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; @@ -363,3 +624,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/DataTypeFactoryTest.php b/tests/Data/Support/DataTypeFactoryTest.php index 0162529ce6..6b0be70114 100644 --- a/tests/Data/Support/DataTypeFactoryTest.php +++ b/tests/Data/Support/DataTypeFactoryTest.php @@ -192,6 +192,29 @@ 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); + } + /** * Test data object declarations and float widening. */ @@ -411,6 +434,15 @@ 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; + public float $float; } diff --git a/tests/Data/Support/PhpDocTypeNameResolverTest.php b/tests/Data/Support/PhpDocTypeNameResolverTest.php index 8c4c7bee2d..6aa5adc54f 100644 --- a/tests/Data/Support/PhpDocTypeNameResolverTest.php +++ b/tests/Data/Support/PhpDocTypeNameResolverTest.php @@ -16,6 +16,8 @@ use ReflectionClass; use ReflectionProperty; +use function Hypervel\Tests\Data\Fixtures\Second\multiNamespaceAnonymous; + class PhpDocTypeNameResolverTest extends TestCase { /** @@ -72,13 +74,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/TransformationContextFactoryTest.php b/tests/Data/Support/Transformation/TransformationContextFactoryTest.php index 70ac909b10..0e03a2327b 100644 --- a/tests/Data/Support/Transformation/TransformationContextFactoryTest.php +++ b/tests/Data/Support/Transformation/TransformationContextFactoryTest.php @@ -8,9 +8,11 @@ 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 stdClass; class TransformationContextFactoryTest extends TestCase @@ -23,6 +25,96 @@ 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 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. */ 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/DataValidatorTest.php b/tests/Data/Support/Validation/DataValidatorTest.php index d0cf185bb6..60594da7f6 100644 --- a/tests/Data/Support/Validation/DataValidatorTest.php +++ b/tests/Data/Support/Validation/DataValidatorTest.php @@ -21,6 +21,7 @@ use Hypervel\Data\Attributes\Validation\CustomValidationAttribute; use Hypervel\Data\Attributes\Validation\Distinct; 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\Sometimes; @@ -52,6 +53,7 @@ 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\ValidationException; use Hypervel\Validation\Validator; @@ -207,6 +209,34 @@ public function testExposesInferredValidationRules(): void $this->assertSame(['string'], $rules['label']); } + 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 configured rule inferrers adjust attribute and inferred rules. */ @@ -1330,7 +1360,7 @@ public function testDirectFactoryFinishedValueDoesNotHideRawSibling(): void try { DirectFinishedParentDataFixture::validateAndCreate([ 'children' => [ - ['finished' => true, 'name' => 'finished'], + 'finished', ['name' => 'invalid'], ], ]); @@ -1688,6 +1718,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. */ @@ -2107,6 +2193,24 @@ public function __construct( } } +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 { /** @@ -3233,13 +3337,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); } /** @@ -3407,6 +3509,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 = [], protected 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( From 54956ca975790971e426b9521aaa0f7977176996 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 4 Oct 2026 12:25:19 +0000 Subject: [PATCH 05/14] Reconcile Laravel Data creation-adjacent tests and fix the defects they exposed Port the next group of spatie/laravel-data tests (main at ce296f2) onto Hypervel's Data package: the From* attribute tests (through Hypervel's contextual attributes), CreationFactoryTest, DataTest, InjectPropertyValuesTest, FillRouteParameterPropertiesDataPipeTest, MagicalCreationTest, MappingTest, PipelineTest, CollectionAttributeWithAnotationsTest, the Model, Json and FormRequest normalizer tests, CreationContextFactoryTest, WithDataTest and the collection annotation reader dataset. Overlapping Hypervel tests are merged under upstream names, and excluded upstream cases carry REMOVED comments. Defects the tests exposed, each with a regression test: - Contextual constructor values skipped conversion, so a RouteParameter('id') int failed for '/posts/123'. They are now converted like unvalidated input after the beforeCreation hooks, and resolved once in the class's build context through the new Container::resolveContextualParameters(). - Container construction, BoundMethod and the routing dispatchers called application code from strict files, so typed route parameters rejected numeric strings that Laravel accepts. NativeInvoker now applies PHP's native weak scalar conversion there; 'abc' still fails with a TypeError. - Data scalar conversion used custom casts. It now follows PHP's weak typing through NativeScalar, keeping the 'true'/'false' and array conversions, so malformed values, including iterable items, fail with a TypeError. - A collection class's own @extends annotation now gives a property its item type after DataCollectionOf and property annotations, with template bounds. - A required data collection infers present instead of required, so an empty list passes as in Spatie. - factory() accepts a CreationContext again and copies its options, never its hooks. - FailOnUnknownFields checked the raw request body even when a custom normalizer produced the source, rejecting valid envelope input. SourceResolver now separates custom normalization from fixed resolution. Upstream's optional FormRequestNormalizer is included. The default still reads a form request like any other request, as upstream does. The README, data-objects documentation and porting guide describe the paginator source requirement, the missing required property failure, the immutable CreationContext, data collection presence, scalar conversion and the optional normalizer. Verified with the Data suite (1136 tests), PHPStan and php-cs-fixer. Before the final Data-only changes, the full parallel suite, FacadeDocblocksTest and the SDK generator suite also ran against this branch. --- .php-cs-fixer.php | 3 + src/container/src/BoundMethod.php | 6 +- src/container/src/Container.php | 49 +- src/container/src/NativeInvoker.php | 38 ++ src/data/README.md | 10 +- src/data/config/data.php | 7 +- src/data/src/Concerns/BaseData.php | 9 +- src/data/src/Contracts/BaseData.php | 5 +- .../src/Normalizers/FormRequestNormalizer.php | 22 + .../DataIterableAnnotationReader.php | 41 ++ src/data/src/Support/Creation/DataCreator.php | 52 +- .../src/Support/Creation/DataInstantiator.php | 17 +- .../src/Support/Creation/NativeScalar.php | 45 ++ .../src/Support/Creation/SourceResolver.php | 30 +- src/data/src/Support/Creation/ValueCaster.php | 14 +- .../src/Support/Factories/DataTypeFactory.php | 6 +- .../Validation/DataValidationCompiler.php | 18 +- src/docs/data-objects.md | 30 +- src/docs/porting-from-laravel.md | 7 +- src/routing/src/CallableDispatcher.php | 3 +- src/routing/src/Controller.php | 5 +- src/routing/src/ControllerDispatcher.php | 5 +- src/support/src/Facades/App.php | 1 + tests/Container/ContainerCallTest.php | 24 + .../ContextualAttributeBindingTest.php | 53 ++ .../FromAuthenticatedUserPropertyTest.php | 44 ++ .../Attributes/FromAuthenticatedUserTest.php | 100 ++++ tests/Data/Attributes/FromContainerTest.php | 98 ++++ .../FromRouteParameterPropertyTest.php | 68 +++ .../Attributes/FromRouteParameterTest.php | 65 +++ tests/Data/CapabilityTest.php | 58 -- tests/Data/Casts/BuiltinTypeCastTest.php | 53 ++ .../Data/Casts/DateTimeInterfaceCastTest.php | 2 +- tests/Data/Casts/EnumCastTest.php | 2 +- .../CollectionAttributeWithAnotationsTest.php | 64 +++ tests/Data/CreationFactoryTest.php | 253 +++++++++ tests/Data/CreationTest.php | 57 +- ...llRouteParameterPropertiesDataPipeTest.php | 156 ++++++ tests/Data/DataTest.php | 151 +++++ .../Fixtures/AbstractData/AbstractData.php | 11 + .../Fixtures/AbstractData/AbstractDataA.php | 16 + .../Fixtures/AbstractData/AbstractDataB.php | 16 + .../Collections/SimpleDataCollection.php | 15 + .../SimpleDataCollectionWithAnotations.php | 17 + .../Concerns/BindsRouteParameters.php | 32 ++ .../DataCollections/CustomDataCollection.php | 11 + .../Data/Fixtures/DataValidationAsserter.php | 77 +++ ...DataWithMultipleArgumentCreationMethod.php | 26 + tests/Data/Fixtures/DataWithNullable.php | 19 + ...WithSimpleDataCollectionWithAnotations.php | 19 + tests/Data/Fixtures/DummyDto.php | 34 ++ tests/Data/Fixtures/LazyData.php | 45 ++ .../Fixtures/Models/DummyModelWithCasts.php | 46 ++ tests/Data/Fixtures/SimpleData.php | 4 +- .../Fixtures/SimpleDataWithMappedProperty.php | 20 + tests/Data/Fixtures/SimpleDto.php | 26 + tests/Data/Fixtures/SimpleResource.php | 26 + tests/Data/InjectPropertyValuesTest.php | 122 ++++ tests/Data/MagicalCreationTest.php | 529 ++++++++++++++++++ tests/Data/MappingTest.php | 386 +++++++++++++ .../Normalizers/FormRequestNormalizerTest.php | 131 +++++ tests/Data/Normalizers/JsonNormalizerTest.php | 52 ++ .../Data/Normalizers/ModelNormalizerTest.php | 264 +++++++++ .../Normalized/NormalizedModelTest.php | 3 +- tests/Data/PipelineTest.php | 61 ++ .../DataIterableAnnotationReaderTest.php | 233 +++++++- .../Creation/CreationContextFactoryTest.php | 175 ++++++ .../Data/Support/Creation/DataCreatorTest.php | 290 +++++----- .../Support/Creation/DataInstantiatorTest.php | 2 +- .../Support/Creation/SourceResolverTest.php | 39 +- .../Data/Support/DataClassRepositoryTest.php | 2 +- tests/Data/Support/DataClassTest.php | 2 +- tests/Data/Support/DataMethodTest.php | 3 +- tests/Data/Support/DataParameterTest.php | 3 +- tests/Data/Support/DataPropertyTest.php | 7 +- tests/Data/Support/DataTypeFactoryTest.php | 39 +- .../Support/Validation/DataValidatorTest.php | 242 +++++++- .../RouteParameterReferenceTest.php | 24 +- tests/Data/WithDataTest.php | 21 +- tests/Routing/RouteDependencyResolverTest.php | 44 ++ 80 files changed, 4469 insertions(+), 336 deletions(-) create mode 100644 src/container/src/NativeInvoker.php create mode 100644 src/data/src/Normalizers/FormRequestNormalizer.php create mode 100644 src/data/src/Support/Creation/NativeScalar.php create mode 100644 tests/Data/Attributes/FromAuthenticatedUserPropertyTest.php create mode 100644 tests/Data/Attributes/FromAuthenticatedUserTest.php create mode 100644 tests/Data/Attributes/FromContainerTest.php create mode 100644 tests/Data/Attributes/FromRouteParameterPropertyTest.php create mode 100644 tests/Data/Attributes/FromRouteParameterTest.php delete mode 100644 tests/Data/CapabilityTest.php create mode 100644 tests/Data/CollectionAttributeWithAnotationsTest.php create mode 100644 tests/Data/CreationFactoryTest.php create mode 100644 tests/Data/DataPipes/FillRouteParameterPropertiesDataPipeTest.php create mode 100644 tests/Data/DataTest.php create mode 100644 tests/Data/Fixtures/AbstractData/AbstractData.php create mode 100644 tests/Data/Fixtures/AbstractData/AbstractDataA.php create mode 100644 tests/Data/Fixtures/AbstractData/AbstractDataB.php create mode 100644 tests/Data/Fixtures/Collections/SimpleDataCollection.php create mode 100644 tests/Data/Fixtures/Collections/SimpleDataCollectionWithAnotations.php create mode 100644 tests/Data/Fixtures/Concerns/BindsRouteParameters.php create mode 100644 tests/Data/Fixtures/DataCollections/CustomDataCollection.php create mode 100644 tests/Data/Fixtures/DataValidationAsserter.php create mode 100644 tests/Data/Fixtures/DataWithMultipleArgumentCreationMethod.php create mode 100644 tests/Data/Fixtures/DataWithNullable.php create mode 100644 tests/Data/Fixtures/DataWithSimpleDataCollectionWithAnotations.php create mode 100644 tests/Data/Fixtures/DummyDto.php create mode 100644 tests/Data/Fixtures/LazyData.php create mode 100644 tests/Data/Fixtures/Models/DummyModelWithCasts.php create mode 100644 tests/Data/Fixtures/SimpleDataWithMappedProperty.php create mode 100644 tests/Data/Fixtures/SimpleDto.php create mode 100644 tests/Data/Fixtures/SimpleResource.php create mode 100644 tests/Data/InjectPropertyValuesTest.php create mode 100644 tests/Data/MagicalCreationTest.php create mode 100644 tests/Data/MappingTest.php create mode 100644 tests/Data/Normalizers/FormRequestNormalizerTest.php create mode 100644 tests/Data/Normalizers/JsonNormalizerTest.php create mode 100644 tests/Data/Normalizers/ModelNormalizerTest.php create mode 100644 tests/Data/PipelineTest.php rename tests/Data/Support/{ => Annotations}/DataIterableAnnotationReaderTest.php (60%) create mode 100644 tests/Data/Support/Creation/CreationContextFactoryTest.php 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/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..15cdef9f04 100755 --- a/src/container/src/Container.php +++ b/src/container/src/Container.php @@ -1756,6 +1756,50 @@ 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. + * + * @param class-string $concrete + * @return array + * + * @throws BindingResolutionException + */ + public function resolveContextualParameters(string $concrete): array + { + $recipe = $this->getBuildRecipe($concrete); + $resolutionState = $this->getOrCreateResolutionState(); + $resolutionState->buildStack[] = $concrete; + + try { + $values = []; + + foreach ($recipe->parameters as $paramRecipe) { + if ($paramRecipe->contextualAttribute === null) { + 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 +1868,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/data/README.md b/src/data/README.md index d406646f98..a574a0b604 100644 --- a/src/data/README.md +++ b/src/data/README.md @@ -8,16 +8,18 @@ Documentation: https://hypervel.org/docs/data-objects 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. -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 built-in normalizer and rule inferrer classes are not 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. +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`. 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. 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. +Hypervel rejects data classes with conflicting input or output mappings when the class is first used, rather than letting one value silently overwrite another. 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. +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. 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). @@ -26,6 +28,8 @@ Spatie's `ContextableData` and `getDataContext()` are not included. An object's 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 [casts and transformers](https://hypervel.org/docs/data-objects#casts-and-transformers). +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. 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. @@ -36,6 +40,8 @@ Spatie's data-specific `From*` attributes, `withOptionalValues()`, `withoutOptio 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 f63e5d8b49..2c39234e8b 100644 --- a/src/data/config/data.php +++ b/src/data/config/data.php @@ -48,9 +48,12 @@ /* * 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, diff --git a/src/data/src/Concerns/BaseData.php b/src/data/src/Concerns/BaseData.php index 27c8e77a79..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; @@ -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; } diff --git a/src/data/src/Contracts/BaseData.php b/src/data/src/Contracts/BaseData.php index a59d72f5f9..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; @@ -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. 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/Support/Annotations/DataIterableAnnotationReader.php b/src/data/src/Support/Annotations/DataIterableAnnotationReader.php index 93e879520c..c5878473d7 100644 --- a/src/data/src/Support/Annotations/DataIterableAnnotationReader.php +++ b/src/data/src/Support/Annotations/DataIterableAnnotationReader.php @@ -5,8 +5,10 @@ namespace Hypervel\Data\Support\Annotations; use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocNode; +use PHPStan\PhpDocParser\Ast\PhpDoc\TemplateTagValueNode; use PHPStan\PhpDocParser\Ast\Type\ArrayTypeNode; use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode; +use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode; use PHPStan\PhpDocParser\Ast\Type\NullableTypeNode; use PHPStan\PhpDocParser\Ast\Type\TypeNode; use PHPStan\PhpDocParser\Ast\Type\UnionTypeNode; @@ -19,6 +21,7 @@ use ReflectionClass; use ReflectionMethod; use ReflectionProperty; +use Traversable; class DataIterableAnnotationReader { @@ -54,6 +57,44 @@ public function getForClass(ReflectionClass $class): array return $annotations; } + /** + * Get the item type a collection class declares for its parent, such as `@extends Collection`. + * + * @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/DataCreator.php b/src/data/src/Support/Creation/DataCreator.php index 5255e692e0..4ac7f7bf42 100644 --- a/src/data/src/Support/Creation/DataCreator.php +++ b/src/data/src/Support/Creation/DataCreator.php @@ -94,13 +94,25 @@ public function __construct( /** * 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) + ->ignoreMagicalMethod(...$creationContext->ignoredMagicalMethods) + ->withCastCollection($creationContext->casts) + ->withNormalizers(...$creationContext->normalizers); + } + $factory = new CreationContextFactory( $this, $this->config, @@ -803,10 +815,17 @@ protected function fillGeneralNode( $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($payload, $normalizers); + $source = SourceResolver::normalize($payload, $normalizers); + + if ($source === null && $payload instanceof Request) { + $fixedRequests[count($sources)] = $payload; + } + + $source ??= SourceResolver::resolve($payload); if ($source === null) { // Input only the cast or a named factory could read stays as given, so validation reports it. @@ -820,9 +839,6 @@ protected function fillGeneralNode( } $sources[] = $source; - $unknownInputSources[] = $payload instanceof Request - ? ($payload->isJson() ? $payload->json()->all() : $payload->request->all()) - : $source; } $propertySources = $sources; @@ -859,6 +875,12 @@ protected function fillGeneralNode( $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), ); @@ -1041,7 +1063,7 @@ protected function fillHookNode( return $this->invokeNamedObjectFactory($dataClass, ...$match); } - $source = SourceResolver::resolve($payload, []); + $source = SourceResolver::resolve($payload); if ($source === null) { // Beneath a cast-owned value, input only the cast or a named factory could read stays as given. @@ -2067,6 +2089,22 @@ protected function castAndInstantiateNode( $properties = $hook($properties); } + // Contextual values are resolved after the hooks so they always win, and a promoted property's value + // is prepared and cast like unvalidated input. Null stays null. + if ($contextualParameters !== []) { + 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); foreach ($state->context->afterCreationHooks as $hook) { diff --git a/src/data/src/Support/Creation/DataInstantiator.php b/src/data/src/Support/Creation/DataInstantiator.php index 8b7849b50a..9b0ba9eabe 100644 --- a/src/data/src/Support/Creation/DataInstantiator.php +++ b/src/data/src/Support/Creation/DataInstantiator.php @@ -4,7 +4,7 @@ namespace Hypervel\Data\Support\Creation; -use Hypervel\Contracts\Container\Container; +use Hypervel\Container\Container; use Hypervel\Data\Contracts\BaseData; use Hypervel\Data\Exceptions\CannotCreateData; use Hypervel\Data\Optional; @@ -38,6 +38,16 @@ 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. + * + * @return array + */ + public function resolveContextualParameters(DataClass $dataClass): array + { + return $this->container->resolveContextualParameters($dataClass->name); + } + /** * Instantiate and assign one fully cast data node. * @@ -58,6 +68,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; 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(mixed $value, array $normalizers): array|Normalized|null + public static function normalize(mixed $value, array $normalizers): array|Normalized|null { - if ($value === null) { - return []; - } - - if ($value instanceof Normalized) { - return $value; + if ($value === null || $value instanceof Normalized) { + return null; } foreach ($normalizers as $normalizer) { @@ -38,6 +36,22 @@ public static function resolve(mixed $value, array $normalizers): array|Normaliz } } + 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; } 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/Factories/DataTypeFactory.php b/src/data/src/Support/Factories/DataTypeFactory.php index 4d8455e9c2..f9b27d37fd 100644 --- a/src/data/src/Support/Factories/DataTypeFactory.php +++ b/src/data/src/Support/Factories/DataTypeFactory.php @@ -16,6 +16,7 @@ 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 +56,7 @@ class DataTypeFactory */ public function __construct( protected readonly PhpDocTypeNameResolver $typeNameResolver, + protected readonly DataIterableAnnotationReader $annotationReader, ) { } @@ -182,7 +184,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, diff --git a/src/data/src/Support/Validation/DataValidationCompiler.php b/src/data/src/Support/Validation/DataValidationCompiler.php index 6ea5df42e2..0ce3856f60 100644 --- a/src/data/src/Support/Validation/DataValidationCompiler.php +++ b/src/data/src/Support/Validation/DataValidationCompiler.php @@ -11,6 +11,7 @@ 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\Sometimes; use Hypervel\Data\Attributes\Validation\StringType; @@ -272,6 +273,7 @@ protected function compileNode( $value, $state, $this->inferredTypeRule($property, $hasValue, $value, $dataType), + $dataIterableClass !== null, $inferredRequired, ); @@ -571,6 +573,7 @@ protected function propertyRules( mixed $value, ConstructionState $state, ?string $typeRule, + bool $receivesDataCollection, bool &$inferredRequired, ): array { $attributes = []; @@ -606,7 +609,7 @@ protected function propertyRules( } } - $inferredRules = $this->inferRules($property, $typeRule, $hasPresenceRule); + $inferredRules = $this->inferRules($property, $typeRule, $hasPresenceRule, $receivesDataCollection); $propertyRules = $this->applyRuleInferrers($property, $attributes, $inferredRules, $nodePath, $state); // An inferrer may remove the inferred requirement; class rules must not treat it as present. $inferredRequired = in_array('required', $inferredRules, true) @@ -638,7 +641,7 @@ protected function propertyRules( $this->ruleDenormalizer->execute($generatedRules, $nodePath), ); } else { - $generatedRules = $this->inferRules($property, $typeRule, $hasPresenceRule); + $generatedRules = $this->inferRules($property, $typeRule, $hasPresenceRule, $receivesDataCollection); $inferredRequired = in_array('required', $generatedRules, true); $rules = $this->mergeRules($attributeRules, $generatedRules); } @@ -658,17 +661,19 @@ protected function propertyRules( * Infer fixed presence and type rules for one property. * * @param null|'array'|'boolean'|'integer'|'numeric'|'string' $typeRule - * @return list<'array'|'boolean'|'integer'|'nullable'|'numeric'|'required'|'sometimes'|'string'> + * @return list<'array'|'boolean'|'integer'|'nullable'|'numeric'|'present'|'required'|'sometimes'|'string'> */ protected function inferRules( DataProperty $property, ?string $typeRule, - bool $hasPresenceRule = false, + bool $hasPresenceRule, + bool $receivesDataCollection, ): array { $rules = match (true) { $property->type->isOptional => ['sometimes'], $property->type->isNullable => ['nullable'], - ! $property->hasDefaultValue && ! $hasPresenceRule => ['required'], + // A list of data objects may be empty, which `required` would reject. + ! $property->hasDefaultValue && ! $hasPresenceRule => [$receivesDataCollection ? 'present' : 'required'], default => [], }; @@ -689,7 +694,7 @@ protected function inferRules( * Explicit attributes take precedence over inferred rules of the same type. * * @param list $attributes - * @param list<'array'|'boolean'|'integer'|'nullable'|'numeric'|'required'|'sometimes'|'string'> $inferredRules + * @param list<'array'|'boolean'|'integer'|'nullable'|'numeric'|'present'|'required'|'sometimes'|'string'> $inferredRules */ protected function applyRuleInferrers( DataProperty $property, @@ -704,6 +709,7 @@ protected function applyRuleInferrers( $rule = match ($inferredRule) { 'sometimes' => new Sometimes, 'nullable' => new Nullable, + 'present' => new Present, 'required' => new Required, 'array' => new ArrayType, 'boolean' => new BooleanType, diff --git a/src/docs/data-objects.md b/src/docs/data-objects.md index e62ec483c7..9f94a87d9d 100644 --- a/src/docs/data-objects.md +++ b/src/docs/data-objects.md @@ -249,7 +249,7 @@ $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`](#casts-and-transformers). ### Defaults, Null, and Optional Values @@ -425,7 +425,7 @@ Mapped input paths may use dot notation. When both the mapped input path and PHP ## 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 @@ -593,7 +593,7 @@ class TeamData extends Data } ``` -The same typed item conversion works for arrays, ordinary collections, lazy collections, and supported paginator types. `DataCollectionOf` is preferred for generated classes because it declares the item type explicitly. +The same typed item conversion works for arrays, ordinary collections, lazy collections, and supported paginator types. A paginator property must receive a Hypervel paginator or paginated data collection, since an array has no pagination details to keep. `DataCollectionOf` is preferred for generated classes because it declares the item type explicitly. ### Backed Enums @@ -669,7 +669,7 @@ A cast implements `Hypervel\Data\Casts\Cast`; a transformer implements `Hypervel A cast on a nested data object or data collection property receives the input before Hypervel builds any nested objects, so it may build them itself. Without validation, the cast receives the value exactly as it was given. When the object is validated, the value is first prepared and checked against the rules of its declared types, as it would be without the cast: models, objects, and collections become the arrays they would be built from, and the cast receives the validated arrays. A value that only the cast or a named method could interpret, such as a string for a nested data object, therefore fails validation. When every cast returns `Uncastable`, Hypervel builds the nested objects from the validated input. Named methods and constructors beneath the cast run only then, and a named method still receives the value it matched. -A cast's `cast` method receives the property, its input value, the object's declared property values keyed by property name, and the `CreationContext`. Undeclared input, contextual constructor values, and computed properties are not included. Properties that come earlier in the class have already been cast. Later properties contain their supplied input, or the default, `Optional`, or `null` value the object will receive when their input is absent. A value the constructor will assign to a property outside the constructor is not known yet: +A cast's `cast` method receives the property, its input value, the object's declared property values keyed by property name, and the `CreationContext`. The context holds the options for the whole creation, so its `dataClass` is the class the creation started with. Undeclared input, contextual constructor values, and computed properties are not included. Properties that come earlier in the class have already been cast. Later properties contain their supplied input, or the default, `Optional`, or `null` value the object will receive when their input is absent. A value the constructor will assign to a property outside the constructor is not known yet: ```php use Hypervel\Data\Casts\Cast; @@ -689,6 +689,14 @@ Use `Castable` when a value class owns its input conversion, `IterableItemCast` 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. +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, +], +``` + 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. @@ -707,7 +715,7 @@ $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. +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. To adjust the rules of every data property, add your own rule inferrers to the `data.rule_inferrers` configuration option. An inferrer implements `Hypervel\Data\RuleInferrers\RuleInferrer`. After Hypervel's own inference, it receives the property, its rules, and a `ValidationContext`: @@ -854,6 +862,14 @@ The `beforeCreation` hook receives the cast values that will be passed to the co 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 that creates another data object may pass its `CreationContext` to `factory()` so the new object uses the same options: + +```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. + ## Transformation @@ -953,7 +969,7 @@ $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. 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. A paginator target requires a Hypervel paginator source because arrays and collections have no pagination details to keep. Eloquent collections become base support collections because data objects are not Eloquent models. 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. @@ -1119,7 +1135,7 @@ 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. +Contextual values are resolved only after validation succeeds. They always take precedence over input and creation hooks, including when the resolved value is `null`. A promoted property's value is converted to its declared type like other input, without validation, so a `'123'` route parameter becomes an `int` and an array becomes a nested data object. Input supplied for a promoted contextual property is allowed by strict unknown-field validation but is ignored. A non-promoted contextual parameter with its own name is passed only to the constructor. Use a named factory or creation hook without the contextual attribute when input should take precedence. `CurrentUser` and `RouteParameter` accept an optional `property` path and use `data_get()` semantics. Accessors and Eloquent relations may run while traversing that path. `RequestAttribute` selects an exact request attribute key. The `Config`, `Context`, and `Give` attributes are also supported, as are custom contextual attributes. diff --git a/src/docs/porting-from-laravel.md b/src/docs/porting-from-laravel.md index ce858b46c8..8ba9b58115 100644 --- a/src/docs/porting-from-laravel.md +++ b/src/docs/porting-from-laravel.md @@ -576,7 +576,7 @@ When porting `spatie/laravel-data`, replace its namespace with `Hypervel\Data` a 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. -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. Typed iterable items are always cast and transformed, and an array given to a collection property becomes that collection. +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: @@ -589,7 +589,10 @@ Review these behavior differences in ported code: - 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. -- 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`. +- `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. 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..38889e56ea 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) * @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/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..f361da98cf 100644 --- a/tests/Container/ContextualAttributeBindingTest.php +++ b/tests/Container/ContextualAttributeBindingTest.php @@ -141,6 +141,32 @@ 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)); + } + public function testAuthedAttribute(): void { $container = new Container; @@ -790,6 +816,33 @@ 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, + ) { + } +} + final class ContainerTestHasConfigValueWithResolvePropertyAndAfterCallback { /** 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/FromContainerTest.php b/tests/Data/Attributes/FromContainerTest.php new file mode 100644 index 0000000000..4e15cbf39c --- /dev/null +++ b/tests/Data/Attributes/FromContainerTest.php @@ -0,0 +1,98 @@ +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/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 6b30a831a4..3537e99d9a 100644 --- a/tests/Data/Casts/BuiltinTypeCastTest.php +++ b/tests/Data/Casts/BuiltinTypeCastTest.php @@ -10,6 +10,7 @@ use Hypervel\Data\Support\DataProperty; use Hypervel\Tests\TestCase; use PHPUnit\Framework\Attributes\DataProvider; +use TypeError; class BuiltinTypeCastTest extends TestCase { @@ -46,8 +47,60 @@ public static function castProvider(): array '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 fa56f2c5b2..c9e7539f47 100644 --- a/tests/Data/Casts/DateTimeInterfaceCastTest.php +++ b/tests/Data/Casts/DateTimeInterfaceCastTest.php @@ -318,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); diff --git a/tests/Data/Casts/EnumCastTest.php b/tests/Data/Casts/EnumCastTest.php index 49ad9b4152..cc902d8310 100644 --- a/tests/Data/Casts/EnumCastTest.php +++ b/tests/Data/Casts/EnumCastTest.php @@ -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( diff --git a/tests/Data/CollectionAttributeWithAnotationsTest.php b/tests/Data/CollectionAttributeWithAnotationsTest.php new file mode 100644 index 0000000000..a9796f136d --- /dev/null +++ b/tests/Data/CollectionAttributeWithAnotationsTest.php @@ -0,0 +1,64 @@ + [ + ['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 + { + // A uniform collection uses one wildcard rule instead of upstream's rule per item. + DataValidationAsserter::for(DataWithSimpleDataCollectionWithAnotations::class) + ->assertOk($this->payload) + ->assertErrors(['collection' => [ + ['notExistingAttribute' => 'xxx'], + ]]) + ->assertRules( + rules: [ + 'collection' => ['present', 'array'], + 'collection.*.string' => ['required', 'string'], + ], + payload: $this->payload + ); + } +} diff --git a/tests/Data/CreationFactoryTest.php b/tests/Data/CreationFactoryTest.php new file mode 100644 index 0000000000..b95b73c1ce --- /dev/null +++ b/tests/Data/CreationFactoryTest.php @@ -0,0 +1,253 @@ +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() + ->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->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 index 54cf4f6d79..395bbc34d3 100644 --- a/tests/Data/CreationTest.php +++ b/tests/Data/CreationTest.php @@ -1238,19 +1238,56 @@ public function testWillCastIterablesIntoDefaultTypes(): void public array $arrays; }; - $data = $dataClass::from([ - 'strings' => ['Hello', 42, 3.14, true, '0', 'false'], - 'bools' => ['Hello', 42, 3.14, true, ['nested'], '0', 'false'], - 'ints' => ['Hello', 42, 3.14, true, ['nested'], '0', 'false'], - 'floats' => ['Hello', 42, 3.14, true, ['nested'], '0', 'false'], - 'arrays' => ['Hello', 42, 3.14, true, ['nested'], '0', 'false'], - ]); + // 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, true, false, false], $data->bools); - $this->assertSame([0, 42, 3, 1, 1, 0, 0], $data->ints); - $this->assertSame([0.0, 42.0, 3.14, 1.0, 1.0, 0.0, 0.0], $data->floats); + $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. 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/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/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 @@ + + */ +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/Concerns/BindsRouteParameters.php b/tests/Data/Fixtures/Concerns/BindsRouteParameters.php new file mode 100644 index 0000000000..8c7b86fb86 --- /dev/null +++ b/tests/Data/Fixtures/Concerns/BindsRouteParameters.php @@ -0,0 +1,32 @@ + $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 + { + try { + $this->dataClass::validate($payload); + } catch (ValidationException $exception) { + if ($errors === null) { + Assert::assertNotEmpty($exception->errors()); + } else { + Assert::assertSame($errors, $exception->errors()); + } + + return $this; + } + + Assert::fail('No validation errors'); + } + + /** + * Assert the rules compiled for the payload. + */ + public function assertRules(array $rules, array $payload = []): self + { + Assert::assertEquals($rules, $this->dataClass::getValidationRules($payload)); + + return $this; + } +} diff --git a/tests/Data/Fixtures/DataWithMultipleArgumentCreationMethod.php b/tests/Data/Fixtures/DataWithMultipleArgumentCreationMethod.php new file mode 100644 index 0000000000..f2db05022d --- /dev/null +++ b/tests/Data/Fixtures/DataWithMultipleArgumentCreationMethod.php @@ -0,0 +1,26 @@ + $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/Models/DummyModelWithCasts.php b/tests/Data/Fixtures/Models/DummyModelWithCasts.php new file mode 100644 index 0000000000..a96e3d5c6e --- /dev/null +++ b/tests/Data/Fixtures/Models/DummyModelWithCasts.php @@ -0,0 +1,46 @@ + 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; + + /** + * Create the model's table. + */ + public static function migrate(): void + { + Schema::create('dummy_model_with_casts', function (Blueprint $blueprint): void { + $blueprint->increments('id'); + + $blueprint->text('data')->nullable(); + $blueprint->text('lazy_data')->nullable(); + $blueprint->text('data_collection')->nullable(); + $blueprint->text('lazy_data_collection')->nullable(); + $blueprint->text('abstract_data')->nullable(); + $blueprint->text('abstract_collection')->nullable(); + }); + } +} diff --git a/tests/Data/Fixtures/SimpleData.php b/tests/Data/Fixtures/SimpleData.php index 566968bf68..89963ebb2c 100644 --- a/tests/Data/Fixtures/SimpleData.php +++ b/tests/Data/Fixtures/SimpleData.php @@ -19,8 +19,8 @@ public function __construct( /** * Create the fixture from a string. */ - public static function fromString(string $string): self + public static function fromString(string $string): static { - return new self($string); + return new static($string); } } diff --git a/tests/Data/Fixtures/SimpleDataWithMappedProperty.php b/tests/Data/Fixtures/SimpleDataWithMappedProperty.php new file mode 100644 index 0000000000..ffc9c68fc2 --- /dev/null +++ b/tests/Data/Fixtures/SimpleDataWithMappedProperty.php @@ -0,0 +1,20 @@ + '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/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 2534df63ae..cba8404131 100644 --- a/tests/Data/Normalizers/Normalized/NormalizedModelTest.php +++ b/tests/Data/Normalizers/Normalized/NormalizedModelTest.php @@ -9,6 +9,7 @@ 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; @@ -115,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( 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/Support/DataIterableAnnotationReaderTest.php b/tests/Data/Support/Annotations/DataIterableAnnotationReaderTest.php similarity index 60% rename from tests/Data/Support/DataIterableAnnotationReaderTest.php rename to tests/Data/Support/Annotations/DataIterableAnnotationReaderTest.php index 42f2152ac8..25c7863b17 100644 --- a/tests/Data/Support/DataIterableAnnotationReaderTest.php +++ b/tests/Data/Support/Annotations/DataIterableAnnotationReaderTest.php @@ -2,8 +2,9 @@ declare(strict_types=1); -namespace Hypervel\Tests\Data\Support; +namespace Hypervel\Tests\Data\Support\Annotations; +use ArrayIterator; use Countable; use DateTimeImmutable; use Hypervel\Config\Repository; @@ -22,18 +23,26 @@ use Hypervel\Data\Support\Factories\DataTypeFactory; use Hypervel\Data\Support\NameMapperResolver; use Hypervel\Data\Support\Types\PhpDocTypeNameResolver; +use Hypervel\Pagination\LengthAwarePaginator; +use Hypervel\Support\Collection; +use Hypervel\Support\Enumerable; 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\Data\Fixtures\Enums\DummyBackedEnum; +use Hypervel\Tests\Data\Fixtures\SimpleData; use Hypervel\Tests\TestCase; +use Iterator; use IteratorAggregate; use PHPStan\PhpDocParser\Lexer\Lexer; use PHPStan\PhpDocParser\Parser\PhpDocParser; +use PHPUnit\Framework\Attributes\DataProvider; use ReflectionClass; use ReflectionMethod; use ReflectionProperty; +use Traversable; class DataIterableAnnotationReaderTest extends TestCase { @@ -157,6 +166,58 @@ public function testParserIsBuiltOnceForEligibleMetadata(): void $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. */ @@ -174,10 +235,10 @@ protected function assertAnnotation( */ protected function factory(DataIterableAnnotationReader $reader): DataClassFactory { - $defaults = require __DIR__ . '/../../../src/data/config/data.php'; + $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, $reader); $parameterFactory = new DataParameterFactory($typeFactory); return new DataClassFactory( @@ -324,3 +385,169 @@ public function __construct( $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 +{ +} diff --git a/tests/Data/Support/Creation/CreationContextFactoryTest.php b/tests/Data/Support/Creation/CreationContextFactoryTest.php new file mode 100644 index 0000000000..ba34460cc4 --- /dev/null +++ b/tests/Data/Support/Creation/CreationContextFactoryTest.php @@ -0,0 +1,175 @@ +get(); + + $this->assertSame(SimpleData::class, $context->dataClass); + $this->assertSame(ValidationStrategy::Always, $context->validationStrategy); + $this->assertTrue($context->mapPropertyNames); + $this->assertFalse($context->disableMagicalCreation); + $this->assertSame([], $context->ignoredMagicalMethods); + $this->assertSame([], $context->casts); + } + + // REMOVED: 'is possible to disable optional values' and 'is possible to enable optional values'; withoutOptionalValues() and withOptionalValues() are not included. + + #[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 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/DataCreatorTest.php b/tests/Data/Support/Creation/DataCreatorTest.php index acae5e1ae3..0d80d652bf 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; @@ -59,6 +62,9 @@ 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; @@ -69,7 +75,9 @@ class DataCreatorTest extends TestCase { - // REMOVED: Configurable pipeline and inherited-context factory tests; use the fixed engine and fresh factory hooks. + use BindsRouteParameters; + + // REMOVED: Configurable pipeline tests; use the fixed engine and factory hooks. // REMOVED: withOptionalValues()/withoutOptionalValues() tests; Optional declarations always preserve absence. // REMOVED: Data-specific From* injection tests; Hypervel contextual attributes cover the same outcomes directly. // REMOVED: UnserializeCast tests; serialized request input is not accepted by a built-in cast. @@ -93,6 +101,28 @@ public function testCreatesMappedDataWithCastsAndFixedAbsenceSemantics(): void $this->assertInstanceOf(Optional::class, $data->note); } + 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 { $later = BasicCreationData::from( @@ -189,117 +219,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; @@ -1124,17 +1043,58 @@ 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 testResolvesIntegerBackedMorphFromNumericString(): void @@ -1675,22 +1635,6 @@ public function testUnrelatedUnionArmPassesThroughAmbiguousDataCollectableTypes( $this->assertSame('unchanged', $data->children); } - public function testCanRestructurePayloadBeforeEnteringThePipeline(): void - { - $instance = PreparedAddressData::from([ - 'name' => '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(), - ); - } - public function testPrepareForPipelineRunsForEachSource(): void { PreparedAddressData::$calls = 0; @@ -1941,11 +1885,14 @@ public function __construct( ) { } - public static function factory(): CreationContextFactory + /** + * Create a factory without property name mapping. + */ + public static function factory(?CreationContext $creationContext = null): CreationContextFactory { ++self::$factoryCalls; - return parent::factory()->withoutPropertyNameMapping(); + return parent::factory($creationContext)->withoutPropertyNameMapping(); } } @@ -2728,13 +2675,76 @@ class NamedFactoryCreationDependency public string $value = 'dependency'; } -class ContextualCreationData extends Data +class ContextualConversionCreationData extends Data { + /** + * Create a fixture whose values come from route parameters. + * + * @param array $items + */ public function __construct( - #[Config('app.name')] - public string $name, + #[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, + ) { + } +} + +interface ContextualCreationContract +{ +} + +class ContextualCreationDefault implements ContextualCreationContract +{ +} + +class ContextualCreationForData implements ContextualCreationContract +{ +} + +#[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; + } +} + +#[Attribute(Attribute::TARGET_CLASS)] +class ContextualCreationMarker +{ +} + +#[ContextualCreationMarker] +class ContextualResolutionCreationData extends Data +{ + public string $label; + + /** + * Create a fixture with promoted and constructor-only contextual values. + */ + public function __construct( + #[ContextualCreationDependency] + public string $dependency, + #[Config('app.name')] + string $appName, ) { + $this->label = $appName; } } diff --git a/tests/Data/Support/Creation/DataInstantiatorTest.php b/tests/Data/Support/Creation/DataInstantiatorTest.php index 00421e8698..a251894f3e 100644 --- a/tests/Data/Support/Creation/DataInstantiatorTest.php +++ b/tests/Data/Support/Creation/DataInstantiatorTest.php @@ -215,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( diff --git a/tests/Data/Support/Creation/SourceResolverTest.php b/tests/Data/Support/Creation/SourceResolverTest.php index e968ad3657..293f2f12f5 100644 --- a/tests/Data/Support/Creation/SourceResolverTest.php +++ b/tests/Data/Support/Creation/SourceResolverTest.php @@ -35,15 +35,26 @@ public function normalize(mixed $value): array|Normalized|null } }; - $this->assertSame([], SourceResolver::resolve(null, [$normalizer])); - $this->assertSame($normalized, SourceResolver::resolve($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 { @@ -59,8 +70,10 @@ public function normalize(mixed $value): array|Normalized|null $this->assertSame( ['custom' => 'value'], - SourceResolver::resolve(['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])); } /** @@ -87,18 +100,18 @@ public function toArray(): array $model = new class extends 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, [])); + $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)); } - public function testReturnsNullWhenNoFixedOrCustomNormalizerAcceptsTheValue(): void + public function testReturnsNullWhenFixedHandlingCannotReadTheValue(): void { foreach ([42, 'not-json', 'null'] as $value) { - $this->assertNull(SourceResolver::resolve($value, [])); + $this->assertNull(SourceResolver::resolve($value)); } } } diff --git a/tests/Data/Support/DataClassRepositoryTest.php b/tests/Data/Support/DataClassRepositoryTest.php index de479951da..c8ebd582d4 100644 --- a/tests/Data/Support/DataClassRepositoryTest.php +++ b/tests/Data/Support/DataClassRepositoryTest.php @@ -110,7 +110,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 41eac3608c..f2762c8e72 100644 --- a/tests/Data/Support/DataClassTest.php +++ b/tests/Data/Support/DataClassTest.php @@ -545,7 +545,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( diff --git a/tests/Data/Support/DataMethodTest.php b/tests/Data/Support/DataMethodTest.php index ba65256d35..2b24d7e1a0 100644 --- a/tests/Data/Support/DataMethodTest.php +++ b/tests/Data/Support/DataMethodTest.php @@ -9,6 +9,7 @@ use Hypervel\Container\Container; 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; @@ -335,7 +336,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, diff --git a/tests/Data/Support/DataParameterTest.php b/tests/Data/Support/DataParameterTest.php index 8f1d2ea2ca..4756f52fa4 100644 --- a/tests/Data/Support/DataParameterTest.php +++ b/tests/Data/Support/DataParameterTest.php @@ -5,6 +5,7 @@ namespace Hypervel\Tests\Data\Support; use Hypervel\Container\Attributes\Config; +use Hypervel\Data\Support\Annotations\DataIterableAnnotationReader; use Hypervel\Data\Support\Factories\DataParameterFactory; use Hypervel\Data\Support\Factories\DataTypeFactory; use Hypervel\Data\Support\Types\PhpDocTypeNameResolver; @@ -20,7 +21,7 @@ class DataParameterTest extends TestCase 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 52d8f5ba13..5ef11ccee6 100644 --- a/tests/Data/Support/DataPropertyTest.php +++ b/tests/Data/Support/DataPropertyTest.php @@ -30,6 +30,7 @@ use Hypervel\Data\Mappers\KebabCaseMapper; use Hypervel\Data\Mappers\SnakeCaseMapper; use Hypervel\Data\Optional; +use Hypervel\Data\Support\Annotations\DataIterableAnnotationReader; use Hypervel\Data\Support\Creation\CreationContext; use Hypervel\Data\Support\DataConfig; use Hypervel\Data\Support\DataProperty; @@ -439,7 +440,7 @@ protected function resolveHelper( foreach ($reflectionClass->getConstructor()?->getParameters() ?? [] as $parameter) { if ($parameter->name === $propertyName) { $constructorParameter = (new DataParameterFactory( - new DataTypeFactory(new PhpDocTypeNameResolver), + new DataTypeFactory(new PhpDocTypeNameResolver, new DataIterableAnnotationReader), ))->build($parameter, $reflectionClass); } } @@ -470,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; @@ -502,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), diff --git a/tests/Data/Support/DataTypeFactoryTest.php b/tests/Data/Support/DataTypeFactoryTest.php index 6b0be70114..a7e39d9f05 100644 --- a/tests/Data/Support/DataTypeFactoryTest.php +++ b/tests/Data/Support/DataTypeFactoryTest.php @@ -18,7 +18,10 @@ 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; @@ -215,6 +218,28 @@ public function testItemShorthandsApplyToAnyContainerAfterExactMatches(): void ], $types); } + 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. */ @@ -244,7 +269,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'); @@ -340,7 +365,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, @@ -443,6 +468,16 @@ class DataTypeFactoryFixture /** @var Collection|DataTypeFactoryFirstItemData[] */ public array|Collection $shorthandWithExactArm; + public SimpleDataCollection $classAnnotatedCollection; + + #[DataCollectionOf(DataTypeFactoryFirstItemData::class)] + public SimpleDataCollection $attributeOverCollectionClass; + + /** @var SimpleDataCollection */ + public SimpleDataCollection $annotationOverCollectionClass; + + public EloquentCollection $eloquentCollection; + public float $float; } diff --git a/tests/Data/Support/Validation/DataValidatorTest.php b/tests/Data/Support/Validation/DataValidatorTest.php index 60594da7f6..fbe23ee4d8 100644 --- a/tests/Data/Support/Validation/DataValidatorTest.php +++ b/tests/Data/Support/Validation/DataValidatorTest.php @@ -78,9 +78,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); @@ -92,12 +93,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()); @@ -105,18 +106,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. */ @@ -209,6 +198,63 @@ public function testExposesInferredValidationRules(): void $this->assertSame(['string'], $rules['label']); } + public function testDataCollectionsMustBePresentButMayBeEmpty(): void + { + $expected = [ + 'items' => ['present', 'array'], + 'nullableItems' => ['nullable', 'array'], + 'optionalItems' => ['sometimes', 'array'], + 'requiredItems' => ['required', 'array'], + '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(['required', 'array'], $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([ @@ -1455,6 +1501,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. */ @@ -2182,6 +2277,66 @@ public function testPrecognitionRetainsWildcardIdentityForSelectedDataRules(): v } } +class PresentCollectionDataFixture extends Data +{ + /** + * Create a fixture with each kind of collection presence. + */ + public function __construct( + #[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, + ) { + } +} + +#[MergeValidationRules] +class MergedRequiredCollectionDataFixture extends Data +{ + /** + * Create a fixture whose collection also has a class rule. + */ + public function __construct( + #[DataCollectionOf(SimpleData::class)] + public array $items, + ) { + } + + /** + * Get class-owned validation rules. + */ + public static function rules(): array + { + return ['items' => ['required']]; + } +} + +class ReplacedCollectionRulesDataFixture extends Data +{ + /** + * Create a fixture whose collection rules are replaced. + */ + public function __construct( + #[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( @@ -3384,6 +3539,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 { @@ -3630,6 +3819,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/References/RouteParameterReferenceTest.php b/tests/Data/Support/Validation/References/RouteParameterReferenceTest.php index bc2dfa386d..d2c68d3256 100644 --- a/tests/Data/Support/Validation/References/RouteParameterReferenceTest.php +++ b/tests/Data/Support/Validation/References/RouteParameterReferenceTest.php @@ -4,7 +4,6 @@ namespace Hypervel\Tests\Data\Support\Validation\References; -use Hypervel\Context\RequestContext; use Hypervel\Contracts\Foundation\Application; use Hypervel\Data\Attributes\Validation\Unique; use Hypervel\Data\Data; @@ -13,12 +12,13 @@ use Hypervel\Data\Support\Validation\Constraints\WhereConstraint; use Hypervel\Data\Support\Validation\References\RouteParameterReference; use Hypervel\Database\Eloquent\Model; -use Hypervel\Http\Request; -use Hypervel\Routing\Route; use Hypervel\Testbench\TestCase; +use Hypervel\Tests\Data\Fixtures\Concerns\BindsRouteParameters; class RouteParameterReferenceTest extends TestCase { + use BindsRouteParameters; + /** * Get package providers for the route parameter reference tests. */ @@ -101,24 +101,6 @@ public function testEachValidationResolvesTheCurrentRequest(): void $this->assertSame('unique:posts,NULL,"1",id', (string) $first['property'][0]); $this->assertSame('unique:posts,NULL,"2",id', (string) $second['property'][0]); } - - /** - * Seed the current request with a route carrying the given parameters. - * - * @param array $parameters - */ - private function bindRouteParameters(array $parameters): void - { - $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); - } } class RouteParameterPost extends Model 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/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 From 49553a6061f551a7047b835486cf154462d01686 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:30:54 +0000 Subject: [PATCH 06/14] Reconcile Laravel Data validation tests and fix the defects they exposed Port spatie/laravel-data's ValidationTest (main at ce296f2) onto Hypervel's Data package with all 105 cases, including the four upstream skips, whose malformed expectations are corrected. Duplicates in the reference, Exists and Unique tests move under the upstream names, and DataValidationAsserter gains upstream's rule explosion and redirect, error-bag, messages and attributes assertions. Database rules with query callbacks now compare by the query the callbacks build, so the callback tests use the query builder the presence verifier passes and assert real constraints. Defects the tests exposed, each with a regression test: - Lifecycle methods were called through "Class::method" strings, which the container reads as Class@method, so anonymous data classes failed. They are now array callables. - Rule assembly now matches upstream on both the default and the configured-inferrer paths: inferred presence and type rules come first, and a declared attribute replaces the inferred rule of its type. nullable and sometimes are inferred independently, a declared presence rule drops only the inferred sometimes, and a supplied defaulted property is required. - Backed-enum properties infer the Enum rule, a declared enum rule replaces it, and the accumulator compares Enum rules by state so uniform collections keep wildcard rules. - Nested input Fill cannot read, including blank strings that skip non-implicit rules, now fails validation at its own path instead of creating an empty item or throwing a TypeError. An unresolved morph adds EnsurePropertyMorphable and cannot be constructed; root input still throws. - A missing or null required data object compiles its children's rules. Class rules and configured inferrers share one context per node with its concrete input path, and an unobserved node receives an empty payload rather than its parent's. - Validated and rules-only creation read only the mapped input name, as upstream does, so validation and construction read the same field. Unvalidated creation keeps the PHP-name fallback. - Wildcard collection attributes are formatted like explicit ones in error messages; validator hooks can still replace the formatter. The container documentation describes resolveContextualParameters(), the data-objects page the mapped-name rule, and the README the integer rule inferred for int properties. Validated with the Data suite (1250 tests), composer analyse and composer lint:fix. --- src/data/README.md | 2 +- src/data/src/Support/Creation/DataCreator.php | 89 +- .../Validation/DataValidationCompiler.php | 241 +- .../src/Support/Validation/DataValidator.php | 26 +- .../Validation/EnsurePropertyMorphable.php | 24 + .../Validation/ValidationAccumulator.php | 6 + src/docs/container.md | 20 + src/docs/data-objects.md | 4 +- .../Data/Attributes/Validation/ExistsTest.php | 12 - .../Data/Attributes/Validation/UniqueTest.php | 12 - .../CollectionAttributeWithAnotationsTest.php | 5 +- tests/Data/Fixtures/CircData.php | 19 + .../Data/Fixtures/DataValidationAsserter.php | 134 +- ...aWithReferenceFieldValidationAttribute.php | 16 + ...aWithContextOverwrittenValidationRules.php | 27 + tests/Data/Fixtures/NestedNullableData.php | 18 + ...ithExplicitValidationRuleAttributeData.php | 20 + .../SimpleDataWithOverwrittenRules.php | 18 + .../Data/Fixtures/Support/FakeInjectable.php | 24 + tests/Data/Fixtures/UlarData.php | 19 + .../PassThroughCustomValidationAttribute.php | 31 + .../Data/Support/Creation/DataCreatorTest.php | 19 +- .../Constraints/DatabaseConstraintTest.php | 14 +- .../Support/Validation/DataValidatorTest.php | 587 ++- .../AuthenticatedUserReferenceTest.php | 29 +- .../References/ContainerReferenceTest.php | 9 - .../RouteParameterReferenceTest.php | 57 +- tests/Data/ValidationTest.php | 3765 +++++++++++++++++ 28 files changed, 4961 insertions(+), 286 deletions(-) create mode 100644 src/data/src/Support/Validation/EnsurePropertyMorphable.php create mode 100644 tests/Data/Fixtures/CircData.php create mode 100644 tests/Data/Fixtures/DataWithReferenceFieldValidationAttribute.php create mode 100644 tests/Data/Fixtures/DummyDataWithContextOverwrittenValidationRules.php create mode 100644 tests/Data/Fixtures/NestedNullableData.php create mode 100644 tests/Data/Fixtures/SimpleDataWithExplicitValidationRuleAttributeData.php create mode 100644 tests/Data/Fixtures/SimpleDataWithOverwrittenRules.php create mode 100644 tests/Data/Fixtures/Support/FakeInjectable.php create mode 100644 tests/Data/Fixtures/UlarData.php create mode 100644 tests/Data/Fixtures/ValidationAttributes/PassThroughCustomValidationAttribute.php create mode 100644 tests/Data/ValidationTest.php diff --git a/src/data/README.md b/src/data/README.md index a574a0b604..43bf2fa752 100644 --- a/src/data/README.md +++ b/src/data/README.md @@ -12,7 +12,7 @@ The `casts`, `transformers`, `normalizers`, and `rule_inferrers` options only ho 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. 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. +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). diff --git a/src/data/src/Support/Creation/DataCreator.php b/src/data/src/Support/Creation/DataCreator.php index 4ac7f7bf42..866cc826a5 100644 --- a/src/data/src/Support/Creation/DataCreator.php +++ b/src/data/src/Support/Creation/DataCreator.php @@ -801,7 +801,7 @@ protected function fillNode( * Fill one post-factory value through the general construction path. * * @param array $payloads - * @return bool false when input beneath a cast-owned value could not be read and was kept as given + * @return bool false when nested input could not be read and was kept as given */ protected function fillGeneralNode( DataClass $dataClass, @@ -828,8 +828,9 @@ protected function fillGeneralNode( $source ??= SourceResolver::resolve($payload); if ($source === null) { - // Input only the cast or a named factory could read stays as given, so validation reports it. - if ($state->executionDeferred()) { + // 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; @@ -842,29 +843,29 @@ protected function fillGeneralNode( } $propertySources = $sources; - $resolvedProperties = $this->resolveProperties($dataClass, $propertySources, $state->context); + $resolvedProperties = $this->resolveProperties($dataClass, $propertySources, $state->context, $compilesRules); if ($state->context->prepareDataHooks !== []) { - $input = $this->prepareDataInput($dataClass, $propertySources, $resolvedProperties, $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); - $resolvedProperties = $this->resolveProperties($dataClass, $propertySources, $state->context); + $propertySources = $this->prepareSourcesForPipeline($dataClass, $propertySources, $state->context, $compilesRules); + $resolvedProperties = $this->resolveProperties($dataClass, $propertySources, $state->context, $compilesRules); } if ($compilesRules) { @@ -882,7 +883,7 @@ protected function fillGeneralNode( } $state->recordUnknownInput( - $this->mergeSources($dataClass, $unknownInputSources, $state->context), + $this->mergeSources($dataClass, $unknownInputSources, $state->context, $compilesRules), ); } @@ -1066,8 +1067,9 @@ protected function fillHookNode( $source = SourceResolver::resolve($payload); if ($source === null) { - // Beneath a cast-owned value, input only the cast or a named factory could read stays as given. - if ($deferred) { + // 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); return null; @@ -1079,12 +1081,12 @@ protected function fillHookNode( if ($match !== null) { $state->recordNamedFactory($dataClass, ...$match); } - $resolvedProperties = $this->resolveProperties($dataClass, [$source], $state->context); - $class = $this->resolveMorphClass($dataClass, $resolvedProperties); + $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)) { @@ -1164,6 +1166,7 @@ protected function fillResolvedProperties( $sources, $payloads, $state->context, + $compilesRules, ); $state->recordAutoLazy($property->name, $autoLazySource); } @@ -1570,15 +1573,16 @@ 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); @@ -1601,7 +1605,7 @@ protected function reconcileNode( $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; foreach ($dataClass->properties as $property) { @@ -1714,6 +1718,7 @@ protected function reconcileProperty( [$payload], [$payload], $state->context, + $compilesRules, ); $state->recordAutoLazy($property->name, $autoLazySource); } @@ -1989,6 +1994,12 @@ protected function castAndInstantiateNode( $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; @@ -2702,11 +2713,12 @@ 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; @@ -2722,12 +2734,13 @@ protected function resolveProperty( array $sources, DataProperty $property, CreationContext $context, + bool $compilesRules, ): array { $mappedKey = $this->propertyInputKey($property, $context); $optional = null; for ($index = count($sources) - 1; $index >= 0; --$index) { - $match = $this->matchPropertySource($sources[$index], $property, $mappedKey); + $match = $this->matchPropertySource($sources[$index], $property, $mappedKey, $compilesRules); if ($match === null) { continue; @@ -2757,6 +2770,7 @@ protected function resolveAutoLazySource( array $sources, array $payloads, CreationContext $context, + bool $compilesRules, ): mixed { if ($this->isAutoWhenLoaded($property)) { foreach ($payloads as $payload) { @@ -2771,7 +2785,7 @@ protected function resolveAutoLazySource( $mappedKey = $this->propertyInputKey($property, $context); for ($index = count($sources) - 1; $index >= 0; --$index) { - $match = $this->matchPropertySource($sources[$index], $property, $mappedKey); + $match = $this->matchPropertySource($sources[$index], $property, $mappedKey, $compilesRules); if ($match !== null && ! $match[1] instanceof Optional) { return $payloads[$index]; @@ -2784,12 +2798,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); @@ -2797,7 +2815,7 @@ protected function matchPropertySource( return [$mappedKey, $value]; } - if ($mappedKey === $property->name) { + if ($compilesRules || $mappedKey === $property->name) { return null; } @@ -2832,13 +2850,14 @@ 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), + is_array($source) ? $source : $this->projectNormalizedSource($dataClass, $source, $context, $compilesRules), ); } @@ -2860,11 +2879,12 @@ protected function prepareDataInput( 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); + : $this->projectNormalizedSource($dataClass, $sources[0], $context, $compilesRules); } $input = $this->mergeArraySources($sources); @@ -2922,13 +2942,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); } @@ -2944,11 +2965,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; @@ -3109,10 +3131,13 @@ protected function invokeNamedObjectFactory( /** * 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; @@ -3136,6 +3161,10 @@ protected function resolveMorphClass(DataClass $dataClass, array $resolvedProper } if ($value instanceof UnknownProperty) { + if ($compilesRules) { + return $dataClass->name; + } + throw CannotCreateAbstractClass::morphClassWasNotResolved($dataClass->name); } @@ -3153,6 +3182,10 @@ protected function resolveMorphClass(DataClass $dataClass, array $resolvedProper $resolvedClass = $class::morph($properties); if ($resolvedClass === null) { + if ($compilesRules) { + return $class; + } + throw CannotCreateAbstractClass::morphClassWasNotResolved($class); } diff --git a/src/data/src/Support/Validation/DataValidationCompiler.php b/src/data/src/Support/Validation/DataValidationCompiler.php index 0ce3856f60..058c7de235 100644 --- a/src/data/src/Support/Validation/DataValidationCompiler.php +++ b/src/data/src/Support/Validation/DataValidationCompiler.php @@ -4,10 +4,12 @@ namespace Hypervel\Data\Support\Validation; +use BackedEnum; 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; @@ -53,6 +55,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. */ @@ -201,6 +220,15 @@ protected function compileNode( ): void { $dataClass = $this->dataClasses->get($class); $contextualProperties = $dataClass->contextualParameters; + // 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); @@ -269,7 +297,9 @@ protected function compileNode( $accumulator->rules[$propertyRulePath] = $this->propertyRules( $property, $path, + $context, $propertyPath, + $hasValue, $value, $state, $this->inferredTypeRule($property, $hasValue, $value, $dataType), @@ -277,6 +307,11 @@ protected function compileNode( $inferredRequired, ); + // 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, @@ -293,6 +328,25 @@ 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, + observed: false, + ); + } + continue; } @@ -328,6 +382,16 @@ protected function compileNode( $lifecycleDeclarations, $compileUnknownFields, ); + + 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], + ); } } @@ -335,6 +399,7 @@ protected function compileNode( $dataClass, $state, $path, + $context, $structuralPath, $accumulator, $observed, @@ -407,11 +472,12 @@ protected function compileDataIterableValues( array &$lifecycleDeclarations, bool $compileUnknownFields, ): 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; } @@ -433,7 +499,7 @@ protected function compileDataIterableValues( } if ($state->isCurrentCollectionUniform() - && ! $hasFinishedValues + && ! $hasNonArrayValues ) { $firstKey = array_key_first($values); $state->enterItem($firstKey); @@ -489,6 +555,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; @@ -561,18 +634,21 @@ 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. * - * @param null|'array'|'boolean'|'integer'|'numeric'|'string' $typeRule - * @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 + * @return list */ protected function propertyRules( DataProperty $property, ValidationPath $nodePath, + ?ValidationContext $context, ValidationPath $propertyPath, + bool $hasValue, mixed $value, ConstructionState $state, - ?string $typeRule, + string|Enum|null $typeRule, bool $receivesDataCollection, bool &$inferredRequired, ): array { @@ -609,8 +685,8 @@ protected function propertyRules( } } - $inferredRules = $this->inferRules($property, $typeRule, $hasPresenceRule, $receivesDataCollection); - $propertyRules = $this->applyRuleInferrers($property, $attributes, $inferredRules, $nodePath, $state); + $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); @@ -626,24 +702,54 @@ protected function propertyRules( } else { $attributeRules = []; $hasPresenceRule = false; + $hasEnumAttribute = false; foreach ($attributes as $attribute) { $denormalizedRules = $this->ruleDenormalizer->execute($attribute, $nodePath); $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( - $attributeRules, $this->ruleDenormalizer->execute($generatedRules, $nodePath), + $attributeRules, ); } else { - $generatedRules = $this->inferRules($property, $typeRule, $hasPresenceRule, $receivesDataCollection); + $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 = $this->mergeRules($attributeRules, $generatedRules); + $rules = [...$generatedRules, ...$attributeRules]; } } @@ -660,28 +766,42 @@ protected function propertyRules( /** * Infer fixed presence and type rules for one property. * - * @param null|'array'|'boolean'|'integer'|'numeric'|'string' $typeRule - * @return list<'array'|'boolean'|'integer'|'nullable'|'numeric'|'present'|'required'|'sometimes'|'string'> + * @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, - ?string $typeRule, + string|object|null $typeRule, bool $hasPresenceRule, bool $receivesDataCollection, + bool $hasValue, ): array { - $rules = match (true) { - $property->type->isOptional => ['sometimes'], - $property->type->isNullable => ['nullable'], + $rules = []; + + 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. - ! $property->hasDefaultValue && ! $hasPresenceRule => [$receivesDataCollection ? 'present' : 'required'], - default => [], - }; + $rules[] = $receivesDataCollection ? 'present' : 'required'; + } if ($typeRule !== null) { $rules[] = $typeRule; } - if ($rules === []) { + if ($rules === [] && ! $hasPresenceRule) { $rules[] = 'sometimes'; } @@ -689,45 +809,30 @@ protected function inferRules( } /** - * Run the configured rule inferrers over one property's attribute and inferred rules. + * Run the configured rule inferrers over one property's inferred and attribute rules. * - * Explicit attributes take precedence over inferred rules of the same type. + * 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'> $inferredRules + * @param list<'array'|'boolean'|'integer'|'nullable'|'numeric'|'present'|'required'|'sometimes'|'string'|Enum> $inferredRules */ protected function applyRuleInferrers( DataProperty $property, array $attributes, array $inferredRules, - ValidationPath $nodePath, + ValidationContext $context, ConstructionState $state, ): PropertyRules { - $rules = new PropertyRules($attributes); + $rules = new PropertyRules; foreach ($inferredRules as $inferredRule) { - $rule = match ($inferredRule) { - 'sometimes' => new Sometimes, - 'nullable' => new Nullable, - 'present' => new Present, - 'required' => new Required, - 'array' => new ArrayType, - 'boolean' => new BooleanType, - 'numeric' => new Numeric, - 'integer' => new IntegerType, - 'string' => new StringType, - }; - - if (! $rules->hasType($rule::class)) { - $rules->add($rule); - } + $rules->add(is_string($inferredRule) ? new (self::INFERRED_RULE_TYPES[$inferredRule]) : $inferredRule); } - $context = new ValidationContext( - payload: $state->currentPayload(), - fullPayload: $state->payload(), - path: $nodePath, - ); + 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); @@ -762,6 +867,7 @@ protected function applyClassRules( DataClass $dataClass, ConstructionState $state, ValidationPath $path, + ?ValidationContext $context, ValidationPath $structuralPath, ValidationAccumulator $accumulator, bool $observed, @@ -770,11 +876,6 @@ protected function applyClassRules( return; } - $context = new ValidationContext( - payload: $observed ? $state->currentPayload() : [], - fullPayload: $state->payload(), - path: $path, - ); $classRules = $this->callArrayLifecycleMethod( $dataClass, 'rules', @@ -840,6 +941,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; } @@ -967,7 +1069,7 @@ protected function callArrayLifecycleMethod( array $parameters = [], ): array { $result = $this->container->call( - "{$dataClass->name}::{$method}", + [$dataClass->name, $method], $parameters, ); @@ -1138,17 +1240,18 @@ protected function translateRuleSegments( ); } - $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); @@ -1380,11 +1483,11 @@ protected function usesDynamicRules( } /** - * Get one unambiguous primitive validation rule. + * Get one unambiguous type validation rule. * - * @return null|'array'|'boolean'|'integer'|'numeric'|'string' + * @return null|'array'|'boolean'|'integer'|'numeric'|'string'|Enum */ - protected function primitiveRule(DataProperty $property): ?string + protected function primitiveRule(DataProperty $property): string|Enum|null { $rules = []; @@ -1392,19 +1495,19 @@ protected function primitiveRule(DataProperty $property): ?string $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' + * @return null|'array'|'boolean'|'integer'|'numeric'|'string'|Enum */ - protected function typeRuleFor(NamedType $type): ?string + protected function typeRuleFor(NamedType $type): string|Enum|null { return match ($type->name) { 'array', 'iterable' => 'array', @@ -1412,7 +1515,9 @@ protected function typeRuleFor(NamedType $type): ?string 'float' => 'numeric', 'int' => 'integer', 'string' => 'string', - default => null, + default => ! $type->builtIn && is_a($type->name, BackedEnum::class, true) + ? new Enum($type->name) + : null, }; } @@ -1422,14 +1527,14 @@ protected function typeRuleFor(NamedType $type): ?string * 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' + * @return null|'array'|'boolean'|'integer'|'numeric'|'string'|Enum */ protected function inferredTypeRule( DataProperty $property, bool $hasValue, mixed $value, ?NamedType $dataType, - ): ?string { + ): 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) { diff --git a/src/data/src/Support/Validation/DataValidator.php b/src/data/src/Support/Validation/DataValidator.php index c622479df2..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()) { @@ -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 @@ +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/docs/container.md b/src/docs/container.md index a390b6a3a3..286f9e790f 100644 --- a/src/docs/container.md +++ b/src/docs/container.md @@ -800,6 +800,26 @@ Nested constructor dependencies are still resolved through the container, so the `buildWith` is the right choice when a class needs parameter overrides and must not be cached, for example a builder object or a class whose constructor captures per-call state. Internally, Hypervel uses this method to instantiate view components so each render gets a fresh instance even though the component class has no explicit binding. +Packages that construct objects themselves sometimes need to change a [contextual attribute](#contextual-attributes) value before the constructor receives it. The `resolveContextualParameters` method resolves the values of a class's contextual attribute parameters, keyed by parameter name, without constructing the class. Contextual bindings for the class and attribute callbacks apply as they would during construction. Pass the values to `buildWith` so they are not resolved again: + +```php +use App\Enums\ReportStatus; +use Hypervel\Container\Attributes\RouteParameter; + +class ReportFilter +{ + public function __construct( + #[RouteParameter('status')] public ReportStatus $status, + ) {} +} + +$parameters = $this->app->resolveContextualParameters(ReportFilter::class); + +$parameters['status'] = ReportStatus::from($parameters['status']); + +$filter = $this->app->buildWith(ReportFilter::class, $parameters); +``` + ### Transient Classes diff --git a/src/docs/data-objects.md b/src/docs/data-objects.md index 9f94a87d9d..6a75f801a1 100644 --- a/src/docs/data-objects.md +++ b/src/docs/data-objects.md @@ -420,7 +420,7 @@ $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. +Mapped input paths may use dot notation. Validated input must use the mapped input path, which is where its rules apply. Without validation, the PHP property name is read when the mapped input is missing. Hypervel rejects a data class when two properties use the same input path or output key instead of silently overwriting a value. ## Type Conversion @@ -812,7 +812,7 @@ public static function rules(ValidationContext $context): array } ``` -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 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 merge 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. 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/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/CollectionAttributeWithAnotationsTest.php b/tests/Data/CollectionAttributeWithAnotationsTest.php index a9796f136d..197c0c0494 100644 --- a/tests/Data/CollectionAttributeWithAnotationsTest.php +++ b/tests/Data/CollectionAttributeWithAnotationsTest.php @@ -47,7 +47,6 @@ public function testCanCreateADataObjectWithACollectionAttributeFromArrayAndBack public function testCanValidateADataObjectWithACollectionAttribute(): void { - // A uniform collection uses one wildcard rule instead of upstream's rule per item. DataValidationAsserter::for(DataWithSimpleDataCollectionWithAnotations::class) ->assertOk($this->payload) ->assertErrors(['collection' => [ @@ -56,7 +55,9 @@ public function testCanValidateADataObjectWithACollectionAttribute(): void ->assertRules( rules: [ 'collection' => ['present', 'array'], - 'collection.*.string' => ['required', 'string'], + 'collection.0.string' => ['required', 'string'], + 'collection.1.string' => ['required', 'string'], + 'collection.2.string' => ['required', 'string'], ], payload: $this->payload ); 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 @@ +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) { - if ($errors === null) { - Assert::assertNotEmpty($exception->errors()); - } else { - Assert::assertSame($errors, $exception->errors()); - } - - return $this; + return $exception; } Assert::fail('No validation errors'); } /** - * Assert the rules compiled for the payload. + * Get the validator configured for the payload. */ - public function assertRules(array $rules, array $payload = []): self + protected function validator(array $payload): Validator { - Assert::assertEquals($rules, $this->dataClass::getValidationRules($payload)); + $validator = null; - return $this; + 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/DataWithReferenceFieldValidationAttribute.php b/tests/Data/Fixtures/DataWithReferenceFieldValidationAttribute.php new file mode 100644 index 0000000000..c39d8d18ee --- /dev/null +++ b/tests/Data/Fixtures/DataWithReferenceFieldValidationAttribute.php @@ -0,0 +1,16 @@ +payload['validate_as_email'] ?? false + ? ['string' => ['required', 'string', 'email']] + : []; + } +} 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 @@ + ['string', 'required', 'min:10', 'max:100'], + ]; + } +} diff --git a/tests/Data/Fixtures/Support/FakeInjectable.php b/tests/Data/Fixtures/Support/FakeInjectable.php new file mode 100644 index 0000000000..6f0b797fd0 --- /dev/null +++ b/tests/Data/Fixtures/Support/FakeInjectable.php @@ -0,0 +1,24 @@ +bind(self::class, fn (): self => new self($value)); + } +} diff --git a/tests/Data/Fixtures/UlarData.php b/tests/Data/Fixtures/UlarData.php new file mode 100644 index 0000000000..30faa9948a --- /dev/null +++ b/tests/Data/Fixtures/UlarData.php @@ -0,0 +1,19 @@ +|object|string + */ + public function getRules(ValidationPath $path): array|object|string + { + return $this->rules; + } +} diff --git a/tests/Data/Support/Creation/DataCreatorTest.php b/tests/Data/Support/Creation/DataCreatorTest.php index 0d80d652bf..7350577380 100644 --- a/tests/Data/Support/Creation/DataCreatorTest.php +++ b/tests/Data/Support/Creation/DataCreatorTest.php @@ -633,18 +633,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 @@ -1463,10 +1472,10 @@ public function testDeclinedCastsBuildChildrenFromValidatedConcreteInput(): void CastOwnedChildCast::$received = null; $data = CastOwnedParentData::validateAndCreate([ - 'child' => ['name' => 'Taylor', 'undeclared' => 'dropped'], + 'child' => ['external_name' => 'Taylor', 'undeclared' => 'dropped'], ]); - $this->assertSame(['name' => 'Taylor'], CastOwnedChildCast::$received); + $this->assertSame(['external_name' => 'Taylor'], CastOwnedChildCast::$received); $this->assertSame('Taylor', $data->child->name); $shapes = CastOwnedShapesData::validateAndCreate([ 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 fbe23ee4d8..5defb266a1 100644 --- a/tests/Data/Support/Validation/DataValidatorTest.php +++ b/tests/Data/Support/Validation/DataValidatorTest.php @@ -20,10 +20,12 @@ 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\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\Sometimes; use Hypervel\Data\Attributes\Validation\StringType; use Hypervel\Data\Attributes\WithoutValidation; @@ -33,6 +35,8 @@ 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; @@ -55,6 +59,7 @@ 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; @@ -204,7 +209,7 @@ public function testDataCollectionsMustBePresentButMayBeEmpty(): void 'items' => ['present', 'array'], 'nullableItems' => ['nullable', 'array'], 'optionalItems' => ['sometimes', 'array'], - 'requiredItems' => ['required', 'array'], + 'requiredItems' => ['array', 'required'], 'plain' => ['required', 'array'], ]; @@ -234,7 +239,7 @@ public function testConfiguredRuleInferrersKeepDataCollectionPresence(): void $rules = PresentCollectionDataFixture::getValidationRules([]); $this->assertSame(['present', 'array'], $rules['items']); - $this->assertSame(['required', 'array'], $rules['requiredItems']); + $this->assertSame(['array', 'required'], $rules['requiredItems']); } public function testClassRulesMergeWithOrReplaceDataCollectionPresence(): void @@ -283,6 +288,74 @@ public function testUnionValuesUseTheTypeRuleOfTheTypeThatHoldsThem(): void } } + /** + * 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. */ @@ -293,11 +366,22 @@ public function testConfiguredRuleInferrersAdjustTheInferredRules(): void $this->assertSame(['required', 'string', 'max:255'], $rules['name']); $this->assertSame(['string', 'max:255', 'sometimes'], $rules['nickname']); - $this->assertSame(['max:20', 'required', 'string'], $rules['code']); - $this->assertSame(['array:id', 'required'], $rules['meta']); + $this->assertSame(['required', 'string', 'max:20'], $rules['code']); + $this->assertSame(['required', 'array:id'], $rules['meta']); $this->assertSame(['required', 'integer'], $rules['age']); } + /** + * 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']); + } + /** * Test merged class presence rules replace a requirement left by the inferrers. */ @@ -324,6 +408,62 @@ public function testRuleInferrersReceiveEachCollectionItemPayload(): void $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. */ @@ -380,7 +520,7 @@ public function testRulesSurvivingInferrersResolveOnce(): void $rules = CountedReferenceDataFixture::getValidationRules([]); - $this->assertSame(['max:10', 'alpha', 'required', 'string'], $rules['name']); + $this->assertSame(['required', 'string', 'max:10', 'alpha'], $rules['name']); $this->assertSame(1, $resolutions); $this->assertSame(1, CountingRuleAttribute::$evaluations); } @@ -399,11 +539,12 @@ public function testValidationContextsIncludeUndeclaredInput(): void $this->assertSame([$payload, $payload], ContextParentDataFixture::$contexts['parent']); $this->assertSame([$payload['child'], $payload], ContextParentDataFixture::$contexts['child']); - $this->assertSame([$payload['child'], $payload], RecordingContextRuleInferrer::$contexts['value']); + [$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 { @@ -413,13 +554,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())); } } @@ -430,7 +610,7 @@ public function testValidationMaterializesLazyCollectionForArrayProperty(): void { $data = ValidatedParentDataFixture::validateAndCreate([ 'children' => LazyCollection::make([ - ['name' => 'Taylor'], + ['profile' => ['name' => 'Taylor']], ]), ]); @@ -445,7 +625,7 @@ public function testValidationRebuildsDeclaredLazyCollection(): void { $data = ValidatedLazyParentDataFixture::validateAndCreate([ 'children' => LazyCollection::make([ - ['name' => 'Taylor'], + ['profile' => ['name' => 'Taylor']], ]), ]); @@ -462,8 +642,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, @@ -473,25 +653,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()), + ); } } @@ -502,14 +684,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); } /** @@ -1138,6 +1320,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. */ @@ -1236,6 +1429,92 @@ public function testValidationOnlyModeBypassesNamedFactories(): void $this->assertSame(99, $data->id); } + /** + * Test nested input Fill cannot read fails validation at its own path instead of creation. + */ + public function testUnreadableNestedInputFailsValidationAtItsPath(): void + { + $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( + ['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. */ @@ -2070,7 +2349,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 { @@ -2086,7 +2365,7 @@ public function testTranslatesNestedMessagesAndAttributesToObservedWirePaths(): LifecycleMessagesParentDataFixture::validateAndCreate([ 'children' => [ ['profile' => ['name' => 123]], - ['name' => 456], + ['profile' => ['name' => 456]], ], ]); $this->fail('Expected nested validation to fail.'); @@ -2097,7 +2376,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'], ); } } @@ -2270,7 +2592,7 @@ 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()); } @@ -2521,22 +2843,6 @@ public function __construct( } } -class RecordingContextRuleInferrer implements RuleInferrer -{ - /** @var array */ - public static array $contexts = []; - - /** - * Record the property's validation context. - */ - public function handle(DataProperty $property, PropertyRules $rules, ValidationContext $context): PropertyRules - { - static::$contexts[$property->name] = [$context->payload, $context->fullPayload]; - - return $rules; - } -} - class ContextChildDataFixture extends Data { /** @@ -2597,6 +2903,22 @@ public function handle(DataProperty $property, PropertyRules $rules, ValidationC } } +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 { /** @@ -2688,6 +3010,116 @@ public function __construct( } } +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 { /** @@ -3127,6 +3559,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( diff --git a/tests/Data/Support/Validation/References/AuthenticatedUserReferenceTest.php b/tests/Data/Support/Validation/References/AuthenticatedUserReferenceTest.php index 92a9e01f57..c651f36f0a 100644 --- a/tests/Data/Support/Validation/References/AuthenticatedUserReferenceTest.php +++ b/tests/Data/Support/Validation/References/AuthenticatedUserReferenceTest.php @@ -6,13 +6,11 @@ use Hypervel\Contracts\Foundation\Application; use Hypervel\Data\Attributes\Validation\Max; -use Hypervel\Data\Attributes\Validation\Unique; use Hypervel\Data\Data; use Hypervel\Data\DataServiceProvider; use Hypervel\Data\Support\Validation\References\AuthenticatedUserReference; use Hypervel\Foundation\Auth\User; use Hypervel\Testbench\TestCase; -use Hypervel\Validation\Rule; class AuthenticatedUserReferenceTest extends TestCase { @@ -32,22 +30,13 @@ protected function defineEnvironment(Application $app): void $app->make('config')->set('auth.guards.admin', ['driver' => 'session', 'provider' => 'users']); } - public function testCanReferenceTheCurrentLoggedInUserAsValuesWithinRules(): void - { - $this->actingAs($user = $this->user(69)); - - $rules = AuthenticatedUserIgnoreData::getValidationRules([]); - - $this->assertSame((string) Rule::unique('users')->ignore($user), (string) $rules['property'][0]); - } - public function testCanReferenceAScalarPropertyOfTheCurrentUser(): void { $this->actingAs($this->user(1, ['max_title_length' => 40])); $rules = AuthenticatedUserPropertyData::getValidationRules([]); - $this->assertSame('max:40', $rules['title'][0]); + $this->assertSame(['required', 'string', 'max:40'], $rules['title']); } public function testResolvesToNullForAGuest(): void @@ -71,8 +60,8 @@ public function testEachValidationResolvesTheCurrentUser(): void $this->actingAs($this->user(2, ['max_title_length' => 20])); $second = AuthenticatedUserPropertyData::getValidationRules([]); - $this->assertSame('max:10', $first['title'][0]); - $this->assertSame('max:20', $second['title'][0]); + $this->assertSame('max:10', $first['title'][2]); + $this->assertSame('max:20', $second['title'][2]); } /** @@ -86,18 +75,6 @@ private function user(int $id, array $attributes = []): User } } -class AuthenticatedUserIgnoreData extends Data -{ - /** - * Create a fixture that ignores the authenticated user. - */ - public function __construct( - #[Unique('users', ignore: new AuthenticatedUserReference)] - public int $property, - ) { - } -} - class AuthenticatedUserPropertyData extends Data { /** diff --git a/tests/Data/Support/Validation/References/ContainerReferenceTest.php b/tests/Data/Support/Validation/References/ContainerReferenceTest.php index a9bd13aa75..d0cbe221db 100644 --- a/tests/Data/Support/Validation/References/ContainerReferenceTest.php +++ b/tests/Data/Support/Validation/References/ContainerReferenceTest.php @@ -22,15 +22,6 @@ protected function getPackageProviders(Application $app): array return [DataServiceProvider::class]; } - public function testCanReferenceAContainerDependencyAsValuesWithinRules(): void - { - $this->app->bind('max-allowed-size', static fn (): int => 100); - - $rules = ContainerReferenceMaxData::getValidationRules([]); - - $this->assertSame('max:100', $rules['property'][0]); - } - public function testResolvesAPropertyOfTheDependency(): void { $this->app->bind('upload-limits', static fn (): array => ['size' => 512]); diff --git a/tests/Data/Support/Validation/References/RouteParameterReferenceTest.php b/tests/Data/Support/Validation/References/RouteParameterReferenceTest.php index d2c68d3256..27571d7010 100644 --- a/tests/Data/Support/Validation/References/RouteParameterReferenceTest.php +++ b/tests/Data/Support/Validation/References/RouteParameterReferenceTest.php @@ -9,7 +9,6 @@ use Hypervel\Data\Data; use Hypervel\Data\DataServiceProvider; use Hypervel\Data\Exceptions\CannotResolveRouteParameterReference; -use Hypervel\Data\Support\Validation\Constraints\WhereConstraint; use Hypervel\Data\Support\Validation\References\RouteParameterReference; use Hypervel\Database\Eloquent\Model; use Hypervel\Testbench\TestCase; @@ -27,34 +26,6 @@ protected function getPackageProviders(Application $app): array return [DataServiceProvider::class]; } - public function testCanUseExternalReferenceAsDatabaseConstraintValue(): void - { - $this->bindRouteParameters(['active' => true]); - - $rules = RouteParameterWhereConstraintData::getValidationRules([]); - - $this->assertSame('unique:users,NULL,NULL,id,is_active,"1"', (string) $rules['email'][0]); - $this->assertSame(['required', 'string'], array_slice($rules['email'], 1)); - } - - public function testCanReferenceRouteParametersAsValuesWithinRules(): void - { - $this->bindRouteParameters(['post_id' => '69']); - - $rules = RouteParameterIgnoreData::getValidationRules([]); - - $this->assertSame('unique:posts,NULL,"69",id', (string) $rules['property'][0]); - } - - public function testCanReferenceRouteModelsWithAPropertyAsValuesWithinRules(): void - { - $this->bindRouteParameters(['post' => RouteParameterPost::make(69)]); - - $rules = RouteParameterModelPropertyData::getValidationRules([]); - - $this->assertSame('unique:posts,NULL,"69",id', (string) $rules['property'][0]); - } - public function testResolvesTheWholeBoundModelWithoutAProperty(): void { $post = RouteParameterPost::make(69); @@ -98,8 +69,8 @@ public function testEachValidationResolvesTheCurrentRequest(): void $this->bindRouteParameters(['post_id' => '2']); $second = RouteParameterIgnoreData::getValidationRules([]); - $this->assertSame('unique:posts,NULL,"1",id', (string) $first['property'][0]); - $this->assertSame('unique:posts,NULL,"2",id', (string) $second['property'][0]); + $this->assertSame('unique:posts,NULL,"1",id', (string) $first['property'][2]); + $this->assertSame('unique:posts,NULL,"2",id', (string) $second['property'][2]); } } @@ -114,18 +85,6 @@ public static function make(int $id): self } } -class RouteParameterWhereConstraintData extends Data -{ - /** - * Create a fixture constrained by a route parameter. - */ - public function __construct( - #[Unique('users', where: [new WhereConstraint('is_active', new RouteParameterReference('active'))])] - public string $email, - ) { - } -} - class RouteParameterIgnoreData extends Data { /** @@ -137,15 +96,3 @@ public function __construct( ) { } } - -class RouteParameterModelPropertyData extends Data -{ - /** - * Create a fixture that ignores a route model's property. - */ - public function __construct( - #[Unique('posts', ignore: new RouteParameterReference('post', 'id'))] - public int $property, - ) { - } -} 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', + ]; + } +} From a8a7f70ec6da2503dd9f2a9ce1162c05303ce356 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 4 Oct 2026 15:20:16 +0000 Subject: [PATCH 07/14] Restore Laravel Data rule normalization and reconcile the validation tests Port spatie/laravel-data's remaining validation tests (main at ce296f2): RulesTest with its full attribute dataset, RuleNormalizerTest, RuleDenormalizerTest (commented out upstream, active here), RequiredRuleInferrerTest and DataClassFromValidationPayloadResolverTest. PasswordTest, ValidationAttributeTest and ValidationPathTest merge the upstream cases under upstream names, and duplicated Hypervel tests are consolidated. Rules declared through #[Rule] were appended as strings, so #[Rule('required|string')] duplicated the inferred required and string rules, and rule inferrers could not see them by type. Upstream's RuleNormalizer and ValidationRuleFactory are restored: the compiler converts a Rule attribute's rules into typed validation attributes, so they replace inferred rules of the same type. The factory mapping stays overridable through mapping(). Defects fixed so normalization never changes what a rule means, each with regression coverage: - Attribute factories silently dropped parameters they could not hold, such as integer:strict. StringValidationAttribute, Exists, Unique and Dimensions now reject parameters they would lose, and such rules stay as written. - AcceptedIf, DeclinedIf and ExcludeIf turned '1' into 'true', changing what a string or integer dependent matches, and the date attributes threw a TypeError for any parseable date. They now keep parsed values as written for the validator to resolve; the unused parse helpers are removed. - The Rule attribute accepts every rule form the validator accepts, including closures, conditional and compilable rules. Native rule objects and their query callbacks keep their identity. Upstream's InvokableRule cases are removed with that deprecated contract; ValidationRule cases replace them. Carbon 3's timezone: replaces tz:. The data-objects page explains that Rule attributes replace inferred rules. Validated with the Data suite (1669 tests), composer analyse and composer lint:fix. --- .../src/Attributes/Validation/AcceptedIf.php | 12 +- src/data/src/Attributes/Validation/After.php | 10 +- .../Attributes/Validation/AfterOrEqual.php | 10 +- src/data/src/Attributes/Validation/Before.php | 10 +- .../Attributes/Validation/BeforeOrEqual.php | 10 +- .../src/Attributes/Validation/DateEquals.php | 10 +- .../src/Attributes/Validation/DeclinedIf.php | 12 +- .../src/Attributes/Validation/Dimensions.php | 12 +- .../src/Attributes/Validation/ExcludeIf.php | 12 +- src/data/src/Attributes/Validation/Exists.php | 5 + src/data/src/Attributes/Validation/Rule.php | 13 +- .../Validation/StringValidationAttribute.php | 14 + src/data/src/Attributes/Validation/Unique.php | 5 + .../Validation/ValidationAttribute.php | 43 - .../CouldNotCreateValidationRule.php | 18 + .../Validation/DataValidationCompiler.php | 11 +- .../src/Support/Validation/RuleNormalizer.php | 131 ++ .../Validation/ValidationRuleFactory.php | 281 ++++ src/docs/data-objects.md | 2 +- .../Attributes/Validation/PasswordTest.php | 34 +- .../Data/Attributes/Validation/RulesTest.php | 134 ++ .../Validation/ValidationAttributeTest.php | 192 +-- .../Fixtures/Rules/CustomHypervelRule.php | 26 + .../Rules/CustomHypervelValidationRule.php | 18 + tests/Data/Fixtures/RulesDataset.php | 1374 +++++++++++++++++ ...ClassFromValidationPayloadResolverTest.php | 59 + .../RequiredRuleInferrerTest.php | 131 ++ .../Support/Validation/DataValidatorTest.php | 19 +- .../Validation/RuleDenormalizerTest.php | 81 + .../Support/Validation/RuleNormalizerTest.php | 230 +++ .../Support/Validation/ValidationPathTest.php | 59 +- .../Validation/ValidationRuleFactoryTest.php | 64 + 32 files changed, 2696 insertions(+), 346 deletions(-) create mode 100644 src/data/src/Exceptions/CouldNotCreateValidationRule.php create mode 100644 src/data/src/Support/Validation/RuleNormalizer.php create mode 100644 src/data/src/Support/Validation/ValidationRuleFactory.php create mode 100644 tests/Data/Attributes/Validation/RulesTest.php create mode 100644 tests/Data/Fixtures/Rules/CustomHypervelRule.php create mode 100644 tests/Data/Fixtures/Rules/CustomHypervelValidationRule.php create mode 100644 tests/Data/Fixtures/RulesDataset.php create mode 100644 tests/Data/Resolvers/DataClassFromValidationPayloadResolverTest.php create mode 100644 tests/Data/RuleInferrers/RequiredRuleInferrerTest.php create mode 100644 tests/Data/Support/Validation/RuleDenormalizerTest.php create mode 100644 tests/Data/Support/Validation/RuleNormalizerTest.php create mode 100644 tests/Data/Support/Validation/ValidationRuleFactoryTest.php 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/Exceptions/CouldNotCreateValidationRule.php b/src/data/src/Exceptions/CouldNotCreateValidationRule.php new file mode 100644 index 0000000000..181eb02650 --- /dev/null +++ b/src/data/src/Exceptions/CouldNotCreateValidationRule.php @@ -0,0 +1,18 @@ +attributes->all(ValidationRule::class) as $recipe) { - $attributes[] = $recipe->newInstance(); + $attribute = $recipe->newInstance(); + + // A Rule attribute's rules become typed attributes where possible, so they replace inferred rules of their type. + if ($attribute instanceof Rule) { + array_push($attributes, ...$this->ruleNormalizer->execute($attribute)); + } else { + $attributes[] = $attribute; + } } $generatedRules = null; diff --git a/src/data/src/Support/Validation/RuleNormalizer.php b/src/data/src/Support/Validation/RuleNormalizer.php new file mode 100644 index 0000000000..6cc7669aa5 --- /dev/null +++ b/src/data/src/Support/Validation/RuleNormalizer.php @@ -0,0 +1,131 @@ + + */ + 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 = []; + + $subRules = Str::contains($rule, 'regex:') ? [$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/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/docs/data-objects.md b/src/docs/data-objects.md index 6a75f801a1..b4a0c2363e 100644 --- a/src/docs/data-objects.md +++ b/src/docs/data-objects.md @@ -740,7 +740,7 @@ class MaxStringLengthRuleInferrer implements RuleInferrer } ``` -Validation attributes take precedence over inferred rules of the same type. Inferrers are resolved from the container for each validation, so they may depend on scoped services. +Validation attributes take precedence over inferred rules of the same type. Rules written in a `Rule` attribute, such as `#[Rule('required|string')]`, count as the matching attributes, so they replace inferred rules too and inferrers see them by type. Inferrers are resolved from the container for each validation, so they may depend on scoped services. 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`. 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/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/Fixtures/Rules/CustomHypervelRule.php b/tests/Data/Fixtures/Rules/CustomHypervelRule.php new file mode 100644 index 0000000000..c8c3444892 --- /dev/null +++ b/tests/Data/Fixtures/Rules/CustomHypervelRule.php @@ -0,0 +1,26 @@ +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/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/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/Support/Validation/DataValidatorTest.php b/tests/Data/Support/Validation/DataValidatorTest.php index 5defb266a1..fec85a86ba 100644 --- a/tests/Data/Support/Validation/DataValidatorTest.php +++ b/tests/Data/Support/Validation/DataValidatorTest.php @@ -26,6 +26,7 @@ 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; @@ -190,19 +191,6 @@ public function testUndeclaredInputBesideADotMappedPropertyRemainsAvailable(): v ); } - /** - * Test inferred rules follow the declared presence and primitive types. - */ - public function testExposesInferredValidationRules(): void - { - $rules = ValidatedDataFixture::getValidationRules(['id' => 1]); - - $this->assertSame(['required', 'integer'], $rules['id']); - $this->assertSame(['nullable', 'string'], $rules['nickname']); - $this->assertSame(['sometimes', 'string'], $rules['note']); - $this->assertSame(['string'], $rules['label']); - } - public function testDataCollectionsMustBePresentButMayBeEmpty(): void { $expected = [ @@ -369,6 +357,8 @@ public function testConfiguredRuleInferrersAdjustTheInferredRules(): void $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']); } /** @@ -380,6 +370,7 @@ public function testDeclaredRulesReplaceTheInferredRuleOfTheirType(): void $this->assertSame(['required', 'string', 'max:20'], $rules['code']); $this->assertSame(['required', 'array:id'], $rules['meta']); + $this->assertSame(['required', 'string'], $rules['ruled']); } /** @@ -2934,6 +2925,8 @@ public function __construct( #[ArrayType('id')] public array $meta, public int $age, + #[Rule('required|string')] + public string $ruled, ) { } } diff --git a/tests/Data/Support/Validation/RuleDenormalizerTest.php b/tests/Data/Support/Validation/RuleDenormalizerTest.php new file mode 100644 index 0000000000..01e766f6e7 --- /dev/null +++ b/tests/Data/Support/Validation/RuleDenormalizerTest.php @@ -0,0 +1,81 @@ +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 '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..92f46e2a30 --- /dev/null +++ b/tests/Data/Support/Validation/RuleNormalizerTest.php @@ -0,0 +1,230 @@ +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 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')); + } +} From c3cdbb67ed27d15e04d3fb91b2935c2ed92a70ee Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 4 Oct 2026 18:11:00 +0000 Subject: [PATCH 08/14] Reconcile Laravel Data transformation and output tests and fix the defects they exposed Port the transformation and output group of spatie/laravel-data tests (main at ce296f2) onto Hypervel's Data package: AppendTest, EmptyTest, PartialsTest, RequestTest, TransformationTest, WrapTest, the transformer tests including SerializeTransformerTest, TransformationContextFactoryTest, DataContextTest, InertiaLazyTest, FromContainerPropertyTest, PartialTest (as PartialTreeTest cases) and the resolver tests, which run through the Hypervel classes that replace the resolvers or through the transformed output. Overlapping Hypervel tests are merged under upstream names, and excluded upstream cases carry REMOVED comments. This also completes an audit of the earlier exclusions and adapted expectations. Defects found by the tests and the audit, each with a regression test: - Paginated collections and paginator properties now transform to Spatie's {data, links, meta} shape in toArray(), toJson() and responses. Responses build links and meta from the original paginator, so a cursor whose ordering field the output renames or hides no longer throws. - A data collection property transforms the items of any iterable a lazy closure returns, and a data iterable at the maximum depth gives []. - SerializeTransformer and withOptionalValues()/withoutOptionalValues() are restored; #[Give] accepts a property path. - Contextual values are validated with their object, resolved once per prepared node through a names filter on Container::resolveContextualParameters(), and prepared again when a hook changes a node's morph class. - A finished value or collection item applies the declared and class rules that target it, with their custom messages; field-only messages still follow mapped input names. - defaultWrap() is restored, and nested data collections in responses keep their own wrapper as in Spatie. - A data collection property without an item class no longer throws on first use. - Items read from a collection by key or in a loop carry its partials without consuming them, and repeated reads no longer pile up partial copies. - An included lazy property that resolves to Optional is omitted. - Inertia's scroll metadata reads a paginated data collection's paginator. Malformed partial paths given in code still throw instead of being ignored or truncated, and TransformationContext stays final; the README records both. The README, data-objects and container documentation and the porting guide are updated. Verified with the Data suite (1918 tests), PHPStan and php-cs-fixer, plus the Container and Inertia suites and FacadeDocblocksTest earlier in the slice. --- src/container/src/Attributes/Give.php | 12 +- src/container/src/Container.php | 7 +- src/data/README.md | 12 +- src/data/src/Concerns/BaseDataCollectable.php | 32 + src/data/src/Concerns/ResponsableData.php | 4 +- src/data/src/Concerns/WrappableData.php | 4 +- src/data/src/DataCollection.php | 8 +- .../src/Exceptions/CannotFindDataClass.php | 23 - .../Http/Resources/DataCollectionResource.php | 24 +- .../Support/Creation/ConstructionState.php | 52 +- .../src/Support/Creation/CreationContext.php | 1 + .../Creation/CreationContextFactory.php | 30 +- src/data/src/Support/Creation/DataCreator.php | 102 +- .../src/Support/Creation/DataInstantiator.php | 10 +- src/data/src/Support/DataClassRepository.php | 13 +- .../Support/Factories/DataPropertyFactory.php | 1 - .../src/Support/Factories/DataTypeFactory.php | 8 +- .../Support/Partials/PartialsDefinition.php | 29 +- .../Transformation/DataTransformer.php | 214 ++- .../Validation/DataValidationCompiler.php | 120 +- .../src/Transformers/SerializeTransformer.php | 19 + src/data/src/Transformers/Transformer.php | 1 - src/docs/container.md | 8 +- src/docs/data-objects.md | 48 +- src/docs/porting-from-laravel.md | 2 +- src/inertia/src/ScrollMetadata.php | 9 +- src/support/src/Facades/App.php | 2 +- .../ContextualAttributeBindingTest.php | 57 + tests/Data/AppendTest.php | 127 ++ .../Attributes/FromContainerPropertyTest.php | 67 + tests/Data/Attributes/FromContainerTest.php | 1 - tests/Data/Concerns/AppendableDataTest.php | 94 +- tests/Data/Concerns/EmptyDataTest.php | 86 +- tests/Data/Concerns/WrappableDataTest.php | 38 +- tests/Data/CreationFactoryTest.php | 2 + tests/Data/CreationTest.php | 113 +- tests/Data/DataCollectionTest.php | 282 ++- tests/Data/EmptyTest.php | 85 + tests/Data/Fixtures/DefaultLazyData.php | 47 + tests/Data/Fixtures/ExceptData.php | 37 + tests/Data/Fixtures/MultiLazyData.php | 45 + tests/Data/Fixtures/MultiNestedData.php | 21 + tests/Data/Fixtures/OnlyData.php | 37 + .../Fixtures/PartialClassConditionalData.php | 112 ++ .../SimpleChildDataWithMappedOutputName.php | 29 + tests/Data/Fixtures/SimpleDataWithWrap.php | 34 + .../ConfidentialDataCollectionTransformer.php | 22 + .../ConfidentialDataTransformer.php | 24 + .../Transformers/StringToUpperTransformer.php | 20 + tests/Data/Http/ResourceResponseTest.php | 58 + tests/Data/Inertia/InertiaIntegrationTest.php | 39 + tests/Data/LazyTest.php | 9 +- tests/Data/PartialsTest.php | 1678 +++++++++++++++++ tests/Data/RequestTest.php | 223 +++ .../Resolvers/DataMorphClassResolverTest.php | 174 ++ .../VisibleDataFieldsResolverTest.php | 1126 +++++++++++ .../Creation/CreationContextFactoryTest.php | 15 +- .../Data/Support/Creation/DataCreatorTest.php | 305 ++- .../Data/Support/DataClassRepositoryTest.php | 11 +- tests/Data/Support/DataClassTest.php | 3 +- tests/Data/Support/Lazy/InertiaLazyTest.php | 27 + tests/Data/Support/NameMapperResolverTest.php | 182 ++ .../PartialsDefinitionTest.php | 51 +- .../Support/PhpDocTypeNameResolverTest.php | 2 + .../Transformation/DataContextTest.php | 59 + .../Transformation/DataTransformerTest.php | 175 +- .../Transformation/EmptyDataResolverTest.php | 254 +++ .../Transformation/PartialTreeTest.php | 50 +- .../TransformationContextFactoryTest.php | 12 + .../Support/Validation/DataValidatorTest.php | 377 ++++ tests/Data/TransformationTest.php | 627 ++++++ .../Transformers/ArrayableTransformerTest.php | 5 +- .../DateTimeInterfaceTransformerTest.php | 25 +- .../Data/Transformers/EnumTransformerTest.php | 5 +- .../Transformers/SerializeTransformerTest.php | 25 + tests/Data/WrapTest.php | 331 ++++ 76 files changed, 7518 insertions(+), 505 deletions(-) create mode 100644 src/data/src/Transformers/SerializeTransformer.php create mode 100644 tests/Data/AppendTest.php create mode 100644 tests/Data/Attributes/FromContainerPropertyTest.php create mode 100644 tests/Data/EmptyTest.php create mode 100644 tests/Data/Fixtures/DefaultLazyData.php create mode 100644 tests/Data/Fixtures/ExceptData.php create mode 100644 tests/Data/Fixtures/MultiLazyData.php create mode 100644 tests/Data/Fixtures/MultiNestedData.php create mode 100644 tests/Data/Fixtures/OnlyData.php create mode 100644 tests/Data/Fixtures/PartialClassConditionalData.php create mode 100644 tests/Data/Fixtures/SimpleChildDataWithMappedOutputName.php create mode 100644 tests/Data/Fixtures/SimpleDataWithWrap.php create mode 100644 tests/Data/Fixtures/Transformers/ConfidentialDataCollectionTransformer.php create mode 100644 tests/Data/Fixtures/Transformers/ConfidentialDataTransformer.php create mode 100644 tests/Data/Fixtures/Transformers/StringToUpperTransformer.php create mode 100644 tests/Data/PartialsTest.php create mode 100644 tests/Data/RequestTest.php create mode 100644 tests/Data/Resolvers/DataMorphClassResolverTest.php create mode 100644 tests/Data/Resolvers/VisibleDataFieldsResolverTest.php create mode 100644 tests/Data/Support/Lazy/InertiaLazyTest.php create mode 100644 tests/Data/Support/NameMapperResolverTest.php rename tests/Data/Support/{Transformation => Partials}/PartialsDefinitionTest.php (76%) create mode 100644 tests/Data/Support/Transformation/DataContextTest.php create mode 100644 tests/Data/Support/Transformation/EmptyDataResolverTest.php create mode 100644 tests/Data/TransformationTest.php create mode 100644 tests/Data/Transformers/SerializeTransformerTest.php create mode 100644 tests/Data/WrapTest.php 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/Container.php b/src/container/src/Container.php index 15cdef9f04..0c63f80d7b 100755 --- a/src/container/src/Container.php +++ b/src/container/src/Container.php @@ -1764,11 +1764,12 @@ public function buildWith(Closure|string $concrete, array $parameters = []): mix * so they are not resolved again. * * @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 + public function resolveContextualParameters(string $concrete, ?array $names = null): array { $recipe = $this->getBuildRecipe($concrete); $resolutionState = $this->getOrCreateResolutionState(); @@ -1778,7 +1779,9 @@ public function resolveContextualParameters(string $concrete): array $values = []; foreach ($recipe->parameters as $paramRecipe) { - if ($paramRecipe->contextualAttribute === null) { + if ($paramRecipe->contextualAttribute === null + || ($names !== null && ! in_array($paramRecipe->name, $names, true)) + ) { continue; } diff --git a/src/data/README.md b/src/data/README.md index 43bf2fa752..c771d94f6c 100644 --- a/src/data/README.md +++ b/src/data/README.md @@ -26,17 +26,25 @@ Spatie's configurable `pipeline()` and custom `DataPipe` classes are not include 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 [partial trees](https://hypervel.org/docs/data-objects#partial-trees). + +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 [casts and transformers](https://hypervel.org/docs/data-objects#casts-and-transformers). 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. See the [porting guide](https://hypervel.org/docs/porting-from-laravel#data-objects). +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. 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/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/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/Http/Resources/DataCollectionResource.php b/src/data/src/Http/Resources/DataCollectionResource.php index 0ebf012573..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; @@ -29,15 +30,10 @@ public function __construct( 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. */ diff --git a/src/data/src/Support/Creation/ConstructionState.php b/src/data/src/Support/Creation/ConstructionState.php index 2c65801502..3b34bdc81d 100644 --- a/src/data/src/Support/Creation/ConstructionState.php +++ b/src/data/src/Support/Creation/ConstructionState.php @@ -48,6 +48,7 @@ final class ConstructionState * autoLazy?: array, * paginatorSource?: AbstractCursorPaginator|AbstractPaginator, * namedFactory?: array{DataClass, DataMethod, DataMethodMatch}, + * contextualPrepared?: true, * uniform?: false, * items?: array * } @@ -191,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. */ @@ -409,7 +432,7 @@ public function resetNodeStructure(): void $node['class'] = null; $node['mappings'] = []; $node['children'] = []; - unset($node['selectedTypes'], $node['autoLazy'], $node['paginatorSource'], $node['namedFactory']); + unset($node['selectedTypes'], $node['autoLazy'], $node['paginatorSource'], $node['namedFactory'], $node['contextualPrepared']); if ($this->pathContainsItem()) { $this->markEnclosingCollectionsNonUniform(); @@ -567,6 +590,32 @@ public function namedFactory(): ?array 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. * @@ -1080,6 +1129,7 @@ private function pathContainsItem(): bool * 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 d360ab4c55..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, diff --git a/src/data/src/Support/Creation/DataCreator.php b/src/data/src/Support/Creation/DataCreator.php index 866cc826a5..d30fbde11c 100644 --- a/src/data/src/Support/Creation/DataCreator.php +++ b/src/data/src/Support/Creation/DataCreator.php @@ -108,6 +108,7 @@ public function factory(string $class, ?CreationContext $creationContext = null) ->validationStrategy($creationContext->validationStrategy) ->withPropertyNameMapping($creationContext->mapPropertyNames) ->withoutMagicalCreation($creationContext->disableMagicalCreation) + ->withOptionalValues($creationContext->useOptionalValues) ->ignoreMagicalMethod(...$creationContext->ignoredMagicalMethods) ->withCastCollection($creationContext->casts) ->withNormalizers(...$creationContext->normalizers); @@ -975,7 +976,7 @@ protected function tryCreateDirectArrayNode( continue; } - if ($property->type->isOptional) { + if ($property->type->isOptional && ($context->useOptionalValues || ! $property->type->isNullable)) { $properties[$property->name] = Optional::create(); continue; @@ -1040,7 +1041,7 @@ protected function tryCreateDirectArrayNode( return $dataClass->directConstructorInstantiation && ! $requiresOrdinaryInstantiation ? $this->instantiator->instantiateDirect($dataClass, $properties) - : $this->instantiator->instantiate($dataClass, $properties); + : $this->instantiator->instantiate($dataClass, $properties, $context->useOptionalValues); } /** @@ -1148,13 +1149,29 @@ protected function fillResolvedProperties( 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); @@ -1607,21 +1624,34 @@ protected function reconcileNode( ? $declaredProperties : $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; } @@ -2004,9 +2034,13 @@ protected function castAndInstantiateNode( // 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 ($property->computed || isset($contextualParameters[$property->name])) { + $contextual = isset($contextualParameters[$property->name]); + + if ($property->computed || ($contextual && ! $contextualPrepared)) { continue; } @@ -2015,6 +2049,20 @@ protected function castAndInstantiateNode( $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 ($property->autoLazy !== null && $property->hasDefaultValue) { $value = $castInputs !== null && array_key_exists($property->name, $castInputs) ? $castInputs[$property->name] @@ -2058,7 +2106,9 @@ protected function castAndInstantiateNode( 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; @@ -2071,7 +2121,7 @@ protected function castAndInstantiateNode( ? $this->castProperty($property, $value, $state, $extensions, $properties, $castInputs) : $this->buildAutoLazy($property, $value, $state, $extensions, $properties, $castInputs); - if ($castInputs !== null) { + if ($castInputs !== null && ! $contextual) { $castInputs[$property->name] = $properties[$property->name]; } } @@ -2096,13 +2146,27 @@ protected function castAndInstantiateNode( } } + $preparedContextualValues = $contextualPrepared + ? array_intersect_key($properties, $contextualParameters) + : []; + foreach ($state->context->beforeCreationHooks as $hook) { $properties = $hook($properties); } - // Contextual values are resolved after the hooks so they always win, and a promoted property's value - // is prepared and cast like unvalidated input. Null stays null. - if ($contextualParameters !== []) { + 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; @@ -2116,7 +2180,7 @@ protected function castAndInstantiateNode( } } - $data = $this->instantiator->instantiate($dataClass, $properties); + $data = $this->instantiator->instantiate($dataClass, $properties, $state->context->useOptionalValues); foreach ($state->context->afterCreationHooks as $hook) { $data = $hook($data); @@ -2608,7 +2672,7 @@ protected function castIterableItem( * * 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 because they are resolved when the object is constructed. + * values are excluded. * * @param array $properties values already cast for this node * @return array @@ -2637,7 +2701,9 @@ protected function castInputs(ConstructionState $state, array $properties): arra $inputs[$name] = $value; } elseif ($property->hasDefaultValue) { $inputs[$name] = $this->propertyDefaultValue($dataClass, $property); - } elseif ($property->type->isOptional) { + } elseif ($property->type->isOptional + && ($state->context->useOptionalValues || ! $property->type->isNullable) + ) { $inputs[$name] = Optional::create(); } elseif ($property->type->isNullable) { $inputs[$name] = null; diff --git a/src/data/src/Support/Creation/DataInstantiator.php b/src/data/src/Support/Creation/DataInstantiator.php index 9b0ba9eabe..62237a50a4 100644 --- a/src/data/src/Support/Creation/DataInstantiator.php +++ b/src/data/src/Support/Creation/DataInstantiator.php @@ -41,19 +41,21 @@ public function instantiateDirect(DataClass $dataClass, array $properties): Base /** * 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 + public function resolveContextualParameters(DataClass $dataClass, ?array $names = null): array { - return $this->container->resolveContextualParameters($dataClass->name); + 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); @@ -118,7 +120,7 @@ public function instantiate(DataClass $dataClass, array $properties): BaseData continue; } - if ($property->type->isOptional) { + if ($property->type->isOptional && ($useOptionalValues || ! $property->type->isNullable)) { $data->{$property->name} = Optional::create(); } elseif ($property->type->isNullable) { $data->{$property->name} = null; diff --git a/src/data/src/Support/DataClassRepository.php b/src/data/src/Support/DataClassRepository.php index bedc92d9c3..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; } diff --git a/src/data/src/Support/Factories/DataPropertyFactory.php b/src/data/src/Support/Factories/DataPropertyFactory.php index 7a9706114f..49dd16d1cb 100644 --- a/src/data/src/Support/Factories/DataPropertyFactory.php +++ b/src/data/src/Support/Factories/DataPropertyFactory.php @@ -127,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, diff --git a/src/data/src/Support/Factories/DataTypeFactory.php b/src/data/src/Support/Factories/DataTypeFactory.php index f9b27d37fd..7766b534fa 100644 --- a/src/data/src/Support/Factories/DataTypeFactory.php +++ b/src/data/src/Support/Factories/DataTypeFactory.php @@ -11,7 +11,6 @@ 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; @@ -81,7 +80,6 @@ public function buildProperty( $reflectionType, $class, $declaringClass, - $typeable, $iterableAnnotations, $collectionOf instanceof DataCollectionOf ? $collectionOf->class : null, true, @@ -112,7 +110,6 @@ public function build( $reflectionType, $class, $this->declaringClass($typeable, $class), - $typeable, ); return new DataType( @@ -157,7 +154,6 @@ protected function buildNativeType( ?ReflectionType $reflectionType, ReflectionClass $targetClass, ReflectionClass $declaringClass, - ReflectionMethod|ReflectionProperty|ReflectionParameter|string $typeable, array $iterableAnnotations = [], ?string $collectionOf = null, bool $forProperty = false, @@ -201,8 +197,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; @@ -216,7 +213,6 @@ protected function buildNativeType( $subType, $targetClass, $declaringClass, - $typeable, $iterableAnnotations, $collectionOf, $forProperty, 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 ac5702c093..115d32f74a 100644 --- a/src/data/src/Support/Transformation/DataTransformer.php +++ b/src/data/src/Support/Transformation/DataTransformer.php @@ -32,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 @@ -129,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, @@ -142,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); } /** @@ -158,13 +163,9 @@ 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) { - if ($context->throwWhenMaxDepthReached) { - throw MaxTransformationDepthReached::create($context->maxDepth); - } - + if ($this->reachedMaxDepth($context)) { return []; } @@ -180,7 +181,7 @@ protected function transformData( $data, $context, $this->transformBulkCopy($data, $dataClass), - $includeAdditionalData, + $resourceRoot, ); } } elseif (($recipe = $dataClass->transformationRecipe) !== null) { @@ -193,7 +194,7 @@ protected function transformData( $data, $context, $this->transformUsingRecipe($data, $recipe, $context, $extensions), - $includeAdditionalData, + $resourceRoot, ); } } @@ -230,10 +231,6 @@ protected function transformData( continue; } - if ($value instanceof Optional) { - continue; - } - if ($value instanceof Lazy) { if (! $this->includesLazy($value, $property, $context)) { continue; @@ -242,6 +239,10 @@ protected function transformData( $value = $value->resolve(); } + if ($value instanceof Optional) { + continue; + } + $value = $this->transformPropertyValue( $property, $value, @@ -260,7 +261,7 @@ protected function transformData( $data, $context, $transformed, - $includeAdditionalData, + $resourceRoot, ); } @@ -273,14 +274,10 @@ 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) { - if ($context->throwWhenMaxDepthReached) { - throw MaxTransformationDepthReached::create($context->maxDepth); - } - + if ($this->reachedMaxDepth($context)) { return []; } @@ -308,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); @@ -321,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. * @@ -344,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 @@ -353,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 @@ -375,10 +384,66 @@ protected function transformPaginatorCollectable( protected function transformPaginator( AbstractPaginator|AbstractCursorPaginator $paginator, array $items, + ?string $wrapKey, ): array { - return (clone $paginator) + 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 { + 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']), + ]; } /** @@ -388,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, + }; } /** @@ -400,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; @@ -565,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)) { @@ -756,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; } /** @@ -882,7 +969,7 @@ protected function propagatePartials( $definitions = $context->partialsForNestedProperty($property); - if (! self::hasResolvedPartials($definitions)) { + if (! PartialsDefinition::hasResolved($definitions)) { return; } @@ -903,7 +990,7 @@ protected function propagateIterablePartials( $definitions = $context->partialsForNestedProperty($property); - if (! self::hasResolvedPartials($definitions)) { + if (! PartialsDefinition::hasResolved($definitions)) { return; } @@ -914,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/Validation/DataValidationCompiler.php b/src/data/src/Support/Validation/DataValidationCompiler.php index 9e231c8823..31a42d3541 100644 --- a/src/data/src/Support/Validation/DataValidationCompiler.php +++ b/src/data/src/Support/Validation/DataValidationCompiler.php @@ -222,6 +222,7 @@ protected function compileNode( ): void { $dataClass = $this->dataClasses->get($class); $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 !== [] @@ -251,7 +252,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, @@ -284,6 +286,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)); + } + + if ($declaredRules !== []) { + $accumulator->rules[$propertyPath->get()] = $declaredRules; + } + if ($compileUnknownFields) { $accumulator->allowedSubtrees[] = $propertyPath->get(); } @@ -654,19 +668,7 @@ protected function propertyRules( bool $receivesDataCollection, bool &$inferredRequired, ): array { - $attributes = []; - - foreach ($property->attributes->all(ValidationRule::class) as $recipe) { - $attribute = $recipe->newInstance(); - - // A Rule attribute's rules become typed attributes where possible, so they replace inferred rules of their type. - if ($attribute instanceof Rule) { - array_push($attributes, ...$this->ruleNormalizer->execute($attribute)); - } else { - $attributes[] = $attribute; - } - } - + $attributes = $this->declaredRuleAttributes($property); $generatedRules = null; foreach ($state->context->beforeRulesHooks as $hook) { @@ -772,6 +774,30 @@ protected function propertyRules( 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. * @@ -1017,6 +1043,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, @@ -1024,6 +1051,7 @@ protected function applyClassMessagesAndAttributes( $path, $structuralPath, $observed, + allowsRuleSuffix: is_string($message), ); } @@ -1097,6 +1125,7 @@ protected function callArrayLifecycleMethod( /** * 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( @@ -1106,6 +1135,7 @@ protected function translateRulePaths( ValidationPath $path, ValidationPath $structuralPath, bool $observed, + bool $allowsRuleSuffix = false, ): array { return $this->translateRuleSegments( ValidationPath::create($key)->rawSegments(), @@ -1115,6 +1145,7 @@ protected function translateRulePaths( $path, $structuralPath, $observed, + $allowsRuleSuffix, ); } @@ -1132,6 +1163,7 @@ protected function translateRuleSegments( ValidationPath $path, ValidationPath $structuralPath, bool $observed, + bool $allowsRuleSuffix = false, ): array { if (! array_key_exists($offset, $segments)) { return [new TranslatedValidationPath( @@ -1154,7 +1186,8 @@ protected function translateRuleSegments( if ($property->computed || ! $property->validate - || isset($dataClass->contextualParameters[$property->name]) + || (isset($dataClass->contextualParameters[$property->name]) + && ! ($observed && $state->contextualValuesPrepared())) ) { return []; } @@ -1166,10 +1199,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, @@ -1177,6 +1206,10 @@ protected function translateRuleSegments( )]; } + if ($property->isFinishedValue($value)) { + return $this->finishedValuePaths($path, $structuralPath, $segments, $offset + 1, $allowsRuleSuffix); + } + $dataType = $this->receivingDataType($property, $state, $hasValue, $value); if ($dataType === null) { @@ -1200,6 +1233,7 @@ protected function translateRuleSegments( $path, $structuralPath, $hasValue && is_array($value), + $allowsRuleSuffix, ); } finally { $state->leave(); @@ -1217,7 +1251,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); @@ -1231,6 +1271,7 @@ protected function translateRuleSegments( $path->item($itemSegment), $structuralPath->item($itemSegment), array_key_exists($itemSegment, $values) && is_array($itemValue), + $allowsRuleSuffix, ); } finally { $state->leave(); @@ -1246,6 +1287,7 @@ protected function translateRuleSegments( $path->wildcard(), $structuralPath->wildcard(), false, + $allowsRuleSuffix, ); } @@ -1276,6 +1318,7 @@ protected function translateRuleSegments( $path->wildcard(), $structuralPath->wildcard(), is_array($values[$firstKey]), + $allowsRuleSuffix, ); } } finally { @@ -1287,6 +1330,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; } @@ -1301,6 +1352,7 @@ protected function translateRuleSegments( $path->item($itemKey), $structuralPath->wildcard(), is_array($itemValue), + $allowsRuleSuffix, )); } finally { $state->leave(); @@ -1313,6 +1365,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. * 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->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 b4a0c2363e..0f1010fd66 100644 --- a/src/docs/data-objects.md +++ b/src/docs/data-objects.md @@ -272,6 +272,14 @@ 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. +When a class should receive `null` instead of `Optional` for some creations, call `withoutOptionalValues()` on its factory. A missing `string|null|Optional` property then receives `null`, while a missing `string|Optional` property still receives `Optional` because it cannot hold `null`: + +```php +$user = UserData::factory() + ->withoutOptionalValues() + ->from($payload); +``` + A public property declared outside the constructor keeps any value the constructor assigned to it when its input is missing or `Optional`; supplied input, including `null`, replaces it. Validation infers its rules from the declaration alone, so when requests may omit such a property, give it a nullable or `Optional` type or add `#[Sometimes]`. @@ -595,6 +603,10 @@ class TeamData extends Data The same typed item conversion works for arrays, ordinary collections, lazy collections, and supported paginator types. A paginator property must receive a Hypervel paginator or paginated data collection, since an array has no pagination details to keep. `DataCollectionOf` is preferred for generated classes because it declares the item type explicitly. +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. + +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. + ### Backed Enums @@ -845,7 +857,7 @@ $users = array_map( 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. -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: +Factories may change the validation strategy, enable or disable name mapping, named factories, and [Optional values](#defaults-null-and-optional-values), ignore selected named methods, and add casts or normalizers. They also provide the following hooks, which run in this order: 1. `prepareData` 2. `beforeValidation` @@ -937,7 +949,7 @@ The ordinary methods apply to the next transformation. Their `Permanently` varia 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. Temporary selections are consumed only when that object is actually reached; collection reads and iteration do not consume them. +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 @@ -994,7 +1006,33 @@ 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. +Responses use Hypervel's JSON resources. Use `wrap()` or `withoutWrapping()` on an object or collection, or define a `defaultWrap()` method returning the key on the class. You may also define the default wrapper using the `data.wrap` configuration option. + +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. + +A paginated collection, or a paginator property, is always wrapped, using `data` when no wrapper is set. Its `links` and `meta` keys describe the current page, and responses, `toArray()`, and `toJson()` all produce the same shape: + +```json +{ + "data": [{"name": "Taylor"}], + "links": [...], + "meta": { + "current_page": 1, + "first_page_url": "https://example.com/users?page=1", + "from": 1, + "last_page": 10, + "last_page_url": "https://example.com/users?page=10", + "next_page_url": "https://example.com/users?page=2", + "path": "https://example.com/users", + "per_page": 15, + "prev_page_url": null, + "to": 15, + "total": 150 + } +} +``` + +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. Override static `jsonOptions()` or `withResponse(Request $request, JsonResponse $response)` for Laravel-style response customization. A custom data collection class may also override `withResponse()`, while its JSON options come from its data class. Responses use the `200` status code for every request method, so set a `201` status for a newly created object yourself: @@ -1135,9 +1173,9 @@ 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`. A promoted property's value is converted to its declared type like other input, without validation, so a `'123'` route parameter becomes an `int` and an array becomes a nested data object. 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. +When the object is validated, a promoted property's contextual value is resolved before validation and checked by the property's rules like any other value, so other rules that reference it see the resolved value. Without validation, it is resolved when the object is constructed. Either way, it takes precedence over input and creation and validation hooks, including when the resolved value is `null`, and is converted to its declared type like other input, so a `'123'` route parameter becomes an `int` and an array becomes a nested data object. Input supplied for a promoted contextual property is allowed by strict unknown-field validation but is ignored. When a rule such as `exclude_if` excludes the value, the property is treated as missing. A non-promoted contextual parameter with its own name is passed only to the constructor. Use a named factory or creation hook without the contextual attribute when input should take precedence. -`CurrentUser` and `RouteParameter` accept an optional `property` path and use `data_get()` semantics. Accessors and Eloquent relations may run while traversing that path. `RequestAttribute` selects an exact request attribute key. The `Config`, `Context`, and `Give` attributes are also supported, as are custom contextual attributes. +`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 diff --git a/src/docs/porting-from-laravel.md b/src/docs/porting-from-laravel.md index 8ba9b58115..c59ca2d954 100644 --- a/src/docs/porting-from-laravel.md +++ b/src/docs/porting-from-laravel.md @@ -574,7 +574,7 @@ 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. +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. 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/support/src/Facades/App.php b/src/support/src/Facades/App.php index 38889e56ea..3c45c14d58 100644 --- a/src/support/src/Facades/App.php +++ b/src/support/src/Facades/App.php @@ -101,7 +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) + * @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/Container/ContextualAttributeBindingTest.php b/tests/Container/ContextualAttributeBindingTest.php index f361da98cf..a471089401 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; @@ -165,6 +187,11 @@ function () use (&$callbacks): void { // 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 testAuthedAttribute(): void @@ -1213,6 +1240,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/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 index 4e15cbf39c..fef40934c3 100644 --- a/tests/Data/Attributes/FromContainerTest.php +++ b/tests/Data/Attributes/FromContainerTest.php @@ -15,7 +15,6 @@ class FromContainerTest extends TestCase { // Spatie's FromContainer is not included; Hypervel's Give contextual attribute resolves a container entry. - // REMOVED: FromContainerPropertyTest; Give has no property path, so read a property in a custom contextual attribute. /** * Get package providers for the test application. 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/CreationFactoryTest.php b/tests/Data/CreationFactoryTest.php index b95b73c1ce..975115db6f 100644 --- a/tests/Data/CreationFactoryTest.php +++ b/tests/Data/CreationFactoryTest.php @@ -222,6 +222,7 @@ public function normalize(mixed $value): ?array ->withoutValidation() ->withoutPropertyNameMapping() ->withoutMagicalCreation() + ->withoutOptionalValues() ->ignoreMagicalMethod('fromString') ->withCast('string', StringToUpperCast::class) ->withNormalizers($normalizer) @@ -235,6 +236,7 @@ public function normalize(mixed $value): ?array $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); diff --git a/tests/Data/CreationTest.php b/tests/Data/CreationTest.php index 395bbc34d3..3b43a77dff 100644 --- a/tests/Data/CreationTest.php +++ b/tests/Data/CreationTest.php @@ -1375,7 +1375,64 @@ public function testIsPossibleToCreateAnUnionTypeDataObject(): void $this->assertInstanceOf(Optional::class, $dataClass::from([])->property); } - // REMOVED: withoutOptionalValues(); Optional declarations always preserve absence. + // 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 { @@ -2137,3 +2194,57 @@ 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/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/DefaultLazyData.php b/tests/Data/Fixtures/DefaultLazyData.php new file mode 100644 index 0000000000..86859262ad --- /dev/null +++ b/tests/Data/Fixtures/DefaultLazyData.php @@ -0,0 +1,47 @@ + $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/ExceptData.php b/tests/Data/Fixtures/ExceptData.php new file mode 100644 index 0000000000..9d0deefc41 --- /dev/null +++ b/tests/Data/Fixtures/ExceptData.php @@ -0,0 +1,37 @@ + $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/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 @@ + '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/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 @@ + (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 @@ + 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( @@ -235,6 +279,7 @@ public function testCollectionJsonOptionsUseTheDeclaredItemClassWithoutInstantia new Collection, [], null, + $transformer, ); $this->assertSame(JSON_UNESCAPED_SLASHES, $resource->jsonOptions()); @@ -328,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( 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/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/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/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/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/Support/Creation/CreationContextFactoryTest.php b/tests/Data/Support/Creation/CreationContextFactoryTest.php index ba34460cc4..01e9ae8a29 100644 --- a/tests/Data/Support/Creation/CreationContextFactoryTest.php +++ b/tests/Data/Support/Creation/CreationContextFactoryTest.php @@ -36,12 +36,11 @@ public function testCreatesAContextFromTheConfig(): void $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); } - // REMOVED: 'is possible to disable optional values' and 'is possible to enable optional values'; withoutOptionalValues() and withOptionalValues() are not included. - #[DataProvider('factoryOptions')] public function testFactoryOptionsRebuildTheCreateContext(Closure $configure, string $option, mixed $expected): void { @@ -121,6 +120,18 @@ public function normalize(mixed $value): ?array 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', diff --git a/tests/Data/Support/Creation/DataCreatorTest.php b/tests/Data/Support/Creation/DataCreatorTest.php index 7350577380..7a68edaf6a 100644 --- a/tests/Data/Support/Creation/DataCreatorTest.php +++ b/tests/Data/Support/Creation/DataCreatorTest.php @@ -23,7 +23,10 @@ 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; @@ -78,7 +81,6 @@ class DataCreatorTest extends TestCase use BindsRouteParameters; // REMOVED: Configurable pipeline tests; use the fixed engine and factory hooks. - // REMOVED: withOptionalValues()/withoutOptionalValues() tests; Optional declarations always preserve absence. // REMOVED: Data-specific From* injection tests; Hypervel contextual attributes cover the same outcomes directly. // REMOVED: UnserializeCast tests; serialized request input is not accepted by a built-in cast. @@ -1106,6 +1108,151 @@ function () use (&$classCallbacks): void { $this->assertSame(1, $classCallbacks); } + public function testValidatedContextualValuesUseTheObjectsRules(): void + { + 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->assertSame(5, $data->id); + $this->assertSame(3, $data->ownerId); + $this->assertSame(1, $data->child?->value); + } + + public function testHooksCannotReplaceValidatedContextualValues(): void + { + 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->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 { $shape = IntegerShapeCreationData::from([ @@ -2739,6 +2886,162 @@ class ContextualCreationMarker { } +#[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) + { + } + + /** + * Count the resolution and return the configured value. + */ + public static function resolve(self $attribute, Container $container): mixed + { + static::$resolutions[$attribute->name] = (static::$resolutions[$attribute->name] ?? 0) + 1; + + return static::$values[$attribute->name] ?? null; + } +} + +class ValidatedContextualData extends Data +{ + /** + * 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 ValidatedContextualChildData extends Data +{ + /** + * Create a nested fixture with a validated contextual value. + */ + public function __construct( + #[ValidatedContextualValue('child'), Max(10)] + public int $value, + public string $name, + ) { + } +} + +class ExcludedContextualData extends Data +{ + #[Computed] + public string $serviceLabel; + + /** + * 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; + } +} + +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, + ) { + } +} + +abstract class ContextualMorphData extends Data implements PropertyMorphableData +{ + /** + * Create a morphable fixture whose subclasses resolve different contextual values. + */ + public function __construct( + #[PropertyForMorph] + public string $type, + ) { + } + + /** + * Get the subclass for the given type. + */ + public static function morph(array $properties): ?string + { + return match ($properties['type']) { + 'first' => FirstContextualMorphData::class, + 'second' => SecondContextualMorphData::class, + }; + } +} + +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 SecondContextualMorphData extends ContextualMorphData +{ + /** + * Create the second morph with a validated contextual value. + */ + public function __construct( + string $type, + #[ValidatedContextualValue('second'), Max(10)] + public int $value, + ) { + parent::__construct($type); + } +} + +class ContextualMorphListData extends Data +{ + /** + * Create a list of contextual morph items. + * + * @param array $items + */ + public function __construct( + #[DataCollectionOf(ContextualMorphData::class)] + public array $items, + ) { + } +} + #[ContextualCreationMarker] class ContextualResolutionCreationData extends Data { diff --git a/tests/Data/Support/DataClassRepositoryTest.php b/tests/Data/Support/DataClassRepositoryTest.php index c8ebd582d4..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), + ); } /** diff --git a/tests/Data/Support/DataClassTest.php b/tests/Data/Support/DataClassTest.php index f2762c8e72..e9ffddce2c 100644 --- a/tests/Data/Support/DataClassTest.php +++ b/tests/Data/Support/DataClassTest.php @@ -311,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); 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 6aa5adc54f..d88863d50d 100644 --- a/tests/Data/Support/PhpDocTypeNameResolverTest.php +++ b/tests/Data/Support/PhpDocTypeNameResolverTest.php @@ -20,6 +20,8 @@ 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. */ 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 489fb26e82..08dc854631 100644 --- a/tests/Data/Support/Transformation/DataTransformerTest.php +++ b/tests/Data/Support/Transformation/DataTransformerTest.php @@ -42,17 +42,15 @@ 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 PHPUnit\Framework\Attributes\DataProvider; use ReflectionProperty; use RuntimeException; use Traversable; class DataTransformerTest extends TestCase { - // REMOVED: SerializeTransformer tests; native PHP serialization owns object serialization. - /** * Get package providers for the transformation test application. */ @@ -177,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, @@ -199,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'])); } /** @@ -461,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)); } /** @@ -805,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. */ @@ -904,59 +972,16 @@ public function testPropagatesPartialsFromAllToLazyDataCollections(): void ], $returned->toArray()); } - /** - * Test nested transformation stops at the configured depth. - */ - public function testThrowsAtMaximumTransformationDepth(): void - { - $data = new NestedData(new NestedData(new SimpleData('deep'))); - - $this->expectExceptionMessageIsOrContains('Max transformation depth of 1 reached.'); - - $data->transform(TransformationContextFactory::create()->maxDepth(1)); - } - - public function testReturnsAnEmptyArrayAtMaximumDepthWhenNotThrowing(): void + public function testReturnsAnEmptyArrayForADataCollectionAtMaximumDepthWhenNotThrowing(): void { - $nested = new NestedData(new NestedData(new SimpleData('deep'))); - $collection = new NestedCollectionOwnerData( + $data = new NestedCollectionOwnerData( new DataCollection(NestedLazyData::class, [new NestedLazyData('first', 'kept')]), ); - $this->assertSame( - ['nested' => []], - $nested->transform(TransformationContextFactory::create()->maxDepth(1, throw: false)), - ); $this->assertSame( ['collection' => []], - $collection->transform(TransformationContextFactory::create()->maxDepth(1, throw: false)), - ); - } - - #[DataProvider('partialOperationProvider')] - public function testNestedPartialsOnUnknownPropertiesThrow(string $operation): void - { - $this->expectException(CannotPerformPartialOnDataField::class); - $this->expectExceptionMessage( - "Cannot apply the [{$operation}] partial to unknown data property [certainly-not-simple]", + $data->transform(TransformationContextFactory::create()->maxDepth(1, throw: false)), ); - - PartialTargetData::make()->{$operation}('certainly-not-simple.string')->toArray(); - } - - /** - * Get the partial operations that validate nested paths. - * - * @return array - */ - public static function partialOperationProvider(): array - { - return [ - 'include' => ['include'], - 'exclude' => ['exclude'], - 'only' => ['only'], - 'except' => ['except'], - ]; } public function testNestedPartialsOnHiddenPropertiesThrow(): void @@ -978,15 +1003,6 @@ public function testFlatUnknownPartialsSelectNothing(): void ); } - #[WithConfig('data.ignore_invalid_partials', true)] - public function testInvalidNestedPartialsCanBeIgnored(): void - { - $this->assertSame( - ['string' => 'World'], - PartialTargetData::make()->include('certainly-not-simple.string', 'string')->toArray(), - ); - } - #[WithConfig('data.max_transformation_depth', 1)] #[WithConfig('data.throw_when_max_transformation_depth_reached', false)] public function testConfiguredDepthReturnsEmptyArraysButPersistenceStillThrows(): void @@ -1487,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, ) { } } @@ -1671,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 f78fcbb1df..0d7361d73c 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. */ @@ -38,6 +63,7 @@ public function testCompilesNestedGroupedAndWildcardPaths(): void $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'); @@ -164,12 +190,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 0e03a2327b..e8bf6ef59d 100644 --- a/tests/Data/Support/Transformation/TransformationContextFactoryTest.php +++ b/tests/Data/Support/Transformation/TransformationContextFactoryTest.php @@ -13,6 +13,7 @@ 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 @@ -104,6 +105,17 @@ public function testCanSetACustomWrapExecutionType(): void $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() diff --git a/tests/Data/Support/Validation/DataValidatorTest.php b/tests/Data/Support/Validation/DataValidatorTest.php index fec85a86ba..aa9cb9bf96 100644 --- a/tests/Data/Support/Validation/DataValidatorTest.php +++ b/tests/Data/Support/Validation/DataValidatorTest.php @@ -5,12 +5,14 @@ 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; @@ -21,6 +23,7 @@ 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; @@ -1549,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. */ @@ -3873,6 +4014,242 @@ public static function rules(ValidationContext $context): array } } +class FinishedExcludedAttributeDataFixture extends Data +{ + /** + * Create a fixture excluding its child through a validation attribute. + */ + public function __construct( + public bool $skip, + #[ExcludeIf('skip', true)] + public ?FinishedValidatedChildDataFixture $child = null, + ) { + } +} + +class FinishedExcludedRuleDataFixture extends Data +{ + /** + * Create a fixture excluding its child through a rule attribute. + */ + public function __construct( + public bool $skip, + #[Rule('exclude_if:skip,true')] + public ?FinishedValidatedChildDataFixture $child = null, + ) { + } +} + +class FinishedExcludedClassRuleDataFixture extends Data +{ + /** + * Create a fixture excluding its child through a class rule. + */ + public function __construct( + public bool $skip, + public ?FinishedValidatedChildDataFixture $child = null, + ) { + } + + /** + * Get class-owned validation rules. + */ + public static function rules(ValidationContext $context): array + { + return ['child' => ['exclude_if:skip,true']]; + } +} + +class FinishedCustomRuleDataFixture extends Data +{ + /** + * Create a fixture checking its child with a custom rule. + */ + public function __construct( + #[Rule(new RejectsBlockedChildRule)] + public FinishedValidatedChildDataFixture $child, + ) { + } +} + +class FinishedProhibitedMessageDataFixture extends Data +{ + /** + * Create a fixture prohibiting its child with a custom message. + */ + public function __construct( + public ?FinishedValidatedChildDataFixture $child = null, + ) { + } + + /** + * Get class-owned validation rules. + */ + public static function rules(ValidationContext $context): array + { + return ['child' => ['prohibited']]; + } + + /** + * Get class-owned validation messages. + */ + public static function messages(): array + { + return ['child.prohibited' => 'A child cannot be given.']; + } +} + +class FinishedProhibitedItemMessageDataFixture extends Data +{ + /** + * Create a fixture prohibiting its first item with a custom message. + * + * @param array $children + */ + public function __construct( + #[DataCollectionOf(FinishedValidatedChildDataFixture::class)] + public array $children, + ) { + } + + /** + * Get class-owned validation rules. + */ + public static function rules(ValidationContext $context): array + { + 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 FinishedProhibitedItemsMessageDataFixture extends Data +{ + /** + * Create a fixture prohibiting every item with a custom message. + * + * @param array $children + */ + public function __construct( + #[DataCollectionOf(FinishedValidatedChildDataFixture::class)] + public array $children, + ) { + } + + /** + * Get class-owned validation rules. + */ + public static function rules(ValidationContext $context): array + { + return ['children.*' => ['prohibited']]; + } + + /** + * Get class-owned validation messages. + */ + public static function messages(): array + { + return ['children.*.prohibited' => 'No child can be given.']; + } +} + +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 +{ + /** + * Create a parent fixture with a mapped nested field. + */ + public function __construct( + public MappedFieldMessageChildDataFixture $child, + ) { + } + + /** + * Get class-owned validation messages. + */ + public static function messages(): array + { + return ['child.name' => 'Supply a name.']; + } +} + +class FinishedExcludedItemDataFixture extends Data +{ + /** + * Create a fixture excluding one finished item. + * + * @param array $children + */ + public function __construct( + public bool $skip, + #[DataCollectionOf(FinishedValidatedChildDataFixture::class)] + public array $children, + ) { + } + + /** + * Get class-owned validation rules. + */ + public static function rules(ValidationContext $context): array + { + return ['children.0' => ['exclude_if:skip,true']]; + } +} + +class FinishedExcludedItemsDataFixture extends Data +{ + /** + * Create a fixture excluding every item. + * + * @param array $children + */ + public function __construct( + public bool $skip, + #[DataCollectionOf(FinishedValidatedChildDataFixture::class)] + public array $children, + ) { + } + + /** + * Get class-owned validation rules. + */ + public static function rules(ValidationContext $context): array + { + 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.'); + } + } +} + class FinishedNestedItemDataFixture extends Data { public function __construct( 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/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; +} From 0faf52ffea169382bf5303085bb0ecc26a3b54bf Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 4 Oct 2026 19:30:05 +0000 Subject: [PATCH 09/14] Reconcile Laravel Data support, Eloquent cast and serialization tests and fix the defects they exposed Port the remaining spatie/laravel-data tests (main at ce296f2) onto Hypervel's Data package: DataPropertyTypeTest, DataMethodTest, DataParameterTest, DataReturnTypeTest (in DataTypeFactoryTest and DataMethodTest), DataAttributesCollectionTest, DataIterableAnnotationReaderTest, both Eloquent cast tests and SerializeableTest, with upstream's migration and model fixtures. Overlapping Hypervel tests are merged under upstream names, and excluded upstream cases carry REMOVED comments. The structure cache, Livewire and TypeScript transformer tests have REMOVED notes in DataServiceProviderTest, because Hypervel has none of those integrations. Defects found by the tests, each with a regression test: - Input no longer reaches protected or private promoted constructor parameters, so from($request) cannot set an object's own state. A required one fails with CannotCreateData, and its class is not instantiated directly from resolved values. - Another collection's annotation, such as @property DataCollection on an array property, gives the property its items when nothing closer matches and no other collection annotation gives a different item type. - The package's internal enums are string-backed, as in Spatie #896, so a creation failure's trace with arguments can be JSON-encoded. - Abstract Eloquent data casts store the subtype's alias when one is registered and its class name otherwise, and read either, as Spatie does. An alias was required, so data stored without one could not be read. The stored type must be a subtype of the declared class, checked before its metadata is built, and then a concrete class, checked with that metadata, before from() runs. - Data collection casts accept collections and other Arrayable values, as Spatie does. Collections keep their data objects, since toArray() would give their output form. The unused DataTypeFactory::buildFromString() is removed. Two upstream annotation fakes documented a parameter they did not declare, so it is added and formatting keeps the tag; two clock-dependent date cases are made deterministic. The README and data-objects documentation are updated. Verified with the Data suite (2103 tests), PHPStan and php-cs-fixer, plus the Inertia suite and AfterEachTestSubscriberTest earlier in the slice. --- src/data/README.md | 2 + src/data/src/Casts/Uncastable.php | 4 +- .../src/Eloquent/AbstractDataEloquentCast.php | 31 +- .../Eloquent/DataCollectionEloquentCast.php | 12 +- src/data/src/Enums/DataPropertyOperation.php | 12 +- src/data/src/Exceptions/CannotCastData.php | 22 +- .../Normalized/UnknownProperty.php | 4 +- .../Support/Creation/AutoLazyReplayMode.php | 6 +- .../src/Support/Creation/CreationMode.php | 8 +- src/data/src/Support/DataClass.php | 2 +- src/data/src/Support/DataConfig.php | 2 +- .../Support/Factories/DataClassFactory.php | 19 +- .../src/Support/Factories/DataTypeFactory.php | 43 +- .../Support/Wrapping/WrapExecutionType.php | 8 +- src/docs/data-objects.md | 8 +- tests/Data/CreationTest.php | 9 +- tests/Data/DataServiceProviderTest.php | 2 +- .../DataCollectionEloquentCastTest.php | 572 +++++--- tests/Data/Eloquent/DataEloquentCastTest.php | 532 +++++--- ...02_12_145757_create_dummy_model_tables.php | 33 + .../Fixtures/Models/DummyModelWithCasts.php | 19 - .../DummyModelWithCustomCollectionCasts.php | 18 + .../Models/DummyModelWithDefaultCasts.php | 22 + .../Models/DummyModelWithEncryptedCasts.php | 27 + .../Fixtures/Models/DummyModelWithJson.php | 19 + tests/Data/Fixtures/SimpleDataCollection.php | 18 + .../Fixtures/SimpleDataWithDefaultValue.php | 26 + tests/Data/SerializeableTest.php | 177 +++ .../DataIterableAnnotationReaderTest.php | 524 +++++++- .../Data/Support/Creation/DataCreatorTest.php | 125 +- .../Support/DataAttributesCollectionTest.php | 151 ++- tests/Data/Support/DataClassTest.php | 45 +- tests/Data/Support/DataMethodTest.php | 358 +++++- tests/Data/Support/DataParameterTest.php | 75 ++ tests/Data/Support/DataPropertyTypeTest.php | 1145 +++++++++++++++++ tests/Data/Support/DataTypeFactoryTest.php | 128 ++ .../Support/Validation/DataValidatorTest.php | 2 +- 37 files changed, 3643 insertions(+), 567 deletions(-) create mode 100644 tests/Data/Fixtures/Migrations/2022_02_12_145757_create_dummy_model_tables.php create mode 100644 tests/Data/Fixtures/Models/DummyModelWithCustomCollectionCasts.php create mode 100644 tests/Data/Fixtures/Models/DummyModelWithDefaultCasts.php create mode 100644 tests/Data/Fixtures/Models/DummyModelWithEncryptedCasts.php create mode 100644 tests/Data/Fixtures/Models/DummyModelWithJson.php create mode 100644 tests/Data/Fixtures/SimpleDataCollection.php create mode 100644 tests/Data/Fixtures/SimpleDataWithDefaultValue.php create mode 100644 tests/Data/SerializeableTest.php create mode 100644 tests/Data/Support/DataPropertyTypeTest.php diff --git a/src/data/README.md b/src/data/README.md index c771d94f6c..4692de1fdd 100644 --- a/src/data/README.md +++ b/src/data/README.md @@ -18,6 +18,8 @@ When a union property already accepts a value, Hypervel keeps it instead of conv 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. 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/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/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/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/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/DataClass.php b/src/data/src/Support/DataClass.php index 0e8f891894..e2ae5b67be 100644 --- a/src/data/src/Support/DataClass.php +++ b/src/data/src/Support/DataClass.php @@ -28,7 +28,7 @@ * @param array $methods * @param list $constructorParameters * @param array $contextualParameters - * @param list $constructorInputs constructor parameters without a public data property, which receive raw input + * @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 diff --git a/src/data/src/Support/DataConfig.php b/src/data/src/Support/DataConfig.php index b34ce035c2..e1b0e4c6b3 100644 --- a/src/data/src/Support/DataConfig.php +++ b/src/data/src/Support/DataConfig.php @@ -147,7 +147,7 @@ public function __construct(Repository $config) } /** - * 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/Factories/DataClassFactory.php b/src/data/src/Support/Factories/DataClassFactory.php index 7269e9a074..1e93585159 100644 --- a/src/data/src/Support/Factories/DataClassFactory.php +++ b/src/data/src/Support/Factories/DataClassFactory.php @@ -163,6 +163,7 @@ public function build(ReflectionClass $reflectionClass): DataClass directConstructorInstantiation: $this->supportsDirectConstructorInstantiation( $reflectionClass, $constructor, + $constructorParameters, $contextualParameters, $constructorInputs, $properties, @@ -238,7 +239,8 @@ protected function resolveReflectionProperties(ReflectionClass $reflectionClass) /** * Get the constructor parameters that receive raw input without a public data property. * - * These are non-promoted parameters without a matching property and promoted non-public parameters. + * 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 array $parameters * @param array $properties @@ -249,7 +251,10 @@ protected function resolveConstructorInputs(array $parameters, array $properties $inputs = []; foreach ($parameters as $parameter) { - if ($parameter->contextualAttribute === null && ! isset($properties[$parameter->name])) { + if ($parameter->contextualAttribute === null + && ! $parameter->isPromoted + && ! isset($properties[$parameter->name]) + ) { $inputs[] = $parameter->name; } } @@ -604,6 +609,7 @@ 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 @@ -611,6 +617,7 @@ protected function resolveCreationRecipe( protected function supportsDirectConstructorInstantiation( ReflectionClass $reflectionClass, ?ReflectionMethod $constructor, + array $constructorParameters, array $contextualParameters, array $constructorInputs, array $properties, @@ -622,6 +629,14 @@ protected function supportsDirectConstructorInstantiation( 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/DataTypeFactory.php b/src/data/src/Support/Factories/DataTypeFactory.php index 7766b534fa..447220a062 100644 --- a/src/data/src/Support/Factories/DataTypeFactory.php +++ b/src/data/src/Support/Factories/DataTypeFactory.php @@ -119,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. * @@ -373,6 +349,7 @@ protected function matchingAnnotation( ): ?DataIterableAnnotation { $fallback = null; $shorthandFallback = null; + $itemFallbacks = []; foreach ($annotations as $annotation) { $container = $this->resolvePhpDocName( @@ -397,12 +374,24 @@ protected function matchingAnnotation( } // Item shorthands such as `Item[]` or `array` describe any container, as in upstream. - if ($shorthandFallback === null && ($container === 'array' || $container === 'iterable')) { - $shorthandFallback = $annotation; + 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()) { + $itemFallbacks[(string) $annotation->itemType] ??= $annotation; } } - return $fallback ?? $shorthandFallback; + return $fallback + ?? $shorthandFallback + ?? (count($itemFallbacks) === 1 ? array_values($itemFallbacks)[0] : null); } /** 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/docs/data-objects.md b/src/docs/data-objects.md index 0f1010fd66..656c2d2688 100644 --- a/src/docs/data-objects.md +++ b/src/docs/data-objects.md @@ -176,7 +176,7 @@ class UserCommand extends Dto } ``` -A constructor parameter without a matching public property, such as a plain parameter or a `protected` promoted property, receives the input value with the same name, unchanged. Name mapping, casts, and inferred validation rules do not apply to it, but rules you declare for it in `rules()` do, and creation and validation hooks may change it. Eloquent models do not supply these values. +A plain constructor parameter without a matching public property receives the input value with the same name, unchanged. Name mapping, casts, and inferred validation rules do not apply to it, but rules you declare for it in `rules()` do, and creation and validation hooks may change it. Eloquent models do not supply these values. A `protected` or `private` promoted property is the object's own state, so input never sets it: it keeps its default value, and creation fails when it has none, unless a `beforeCreation` hook or a named factory supplies it. ```php use Hypervel\Data\Attributes\Computed; @@ -1129,11 +1129,13 @@ class User extends Model } ``` +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()`: +Both casts support `encrypted` and `default` arguments. A value cast to an abstract data class is stored with the class name of its concrete subtype, unless the abstract class selects its subtype through `PropertyMorphableData::morph()`. Registering aliases stores short names instead, which stay valid when classes are renamed: ```php use Hypervel\Data\Support\DataConfig; @@ -1147,7 +1149,7 @@ public function boot(DataConfig $data): void } ``` -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. For more information on Eloquent casts, see the [Eloquent mutators and casts documentation](/docs/{{version}}/eloquent-mutators#data-object-casting). diff --git a/tests/Data/CreationTest.php b/tests/Data/CreationTest.php index 3b43a77dff..c4b3333120 100644 --- a/tests/Data/CreationTest.php +++ b/tests/Data/CreationTest.php @@ -110,6 +110,9 @@ protected function migrateFreshUsing(): array 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, @@ -171,6 +174,8 @@ public function testCanUseDefaultTypesToCreateDataObjects(): void public function testWontCastAPropertyThatIsAlreadyInTheCorrectType(): void { + $explicitCast = DateTime::createFromFormat('d-m-Y', '16-06-1994'); + $data = ComplicatedData::from([ 'withoutType' => 42, 'int' => 42, @@ -180,7 +185,7 @@ public function testWontCastAPropertyThatIsAlreadyInTheCorrectType(): void 'array' => [1, 1, 2, 3, 5, 8], 'nullable' => null, 'mixed' => 42, - 'explicitCast' => DateTime::createFromFormat('d-m-Y', '16-06-1994'), + 'explicitCast' => $explicitCast, 'defaultCast' => DateTime::createFromFormat(DATE_ATOM, '1994-05-16T12:00:00+02:00'), 'nestedData' => SimpleData::from('hello'), 'nestedCollection' => SimpleData::collect([ @@ -201,7 +206,7 @@ public function testWontCastAPropertyThatIsAlreadyInTheCorrectType(): void $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->assertEquals(DateTime::createFromFormat('d-m-Y', '16-06-1994'), $data->explicitCast); + $this->assertSame($explicitCast, $data->explicitCast); $this->assertEquals(SimpleData::from('hello'), $data->nestedData); $this->assertEquals(SimpleData::collect([ SimpleData::from('never'), diff --git a/tests/Data/DataServiceProviderTest.php b/tests/Data/DataServiceProviderTest.php index 1735537318..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 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/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/DummyModelWithCasts.php b/tests/Data/Fixtures/Models/DummyModelWithCasts.php index a96e3d5c6e..5d68a12ee8 100644 --- a/tests/Data/Fixtures/Models/DummyModelWithCasts.php +++ b/tests/Data/Fixtures/Models/DummyModelWithCasts.php @@ -6,8 +6,6 @@ use Hypervel\Data\DataCollection; use Hypervel\Database\Eloquent\Model; -use Hypervel\Database\Schema\Blueprint; -use Hypervel\Support\Facades\Schema; use Hypervel\Tests\Data\Fixtures\AbstractData\AbstractData; use Hypervel\Tests\Data\Fixtures\LazyData; use Hypervel\Tests\Data\Fixtures\SimpleData; @@ -26,21 +24,4 @@ class DummyModelWithCasts extends Model ]; public bool $timestamps = false; - - /** - * Create the model's table. - */ - public static function migrate(): void - { - Schema::create('dummy_model_with_casts', function (Blueprint $blueprint): void { - $blueprint->increments('id'); - - $blueprint->text('data')->nullable(); - $blueprint->text('lazy_data')->nullable(); - $blueprint->text('data_collection')->nullable(); - $blueprint->text('lazy_data_collection')->nullable(); - $blueprint->text('abstract_data')->nullable(); - $blueprint->text('abstract_collection')->nullable(); - }); - } } 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/SimpleDataCollection.php b/tests/Data/Fixtures/SimpleDataCollection.php new file mode 100644 index 0000000000..7b49f50693 --- /dev/null +++ b/tests/Data/Fixtures/SimpleDataCollection.php @@ -0,0 +1,18 @@ +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 index 25c7863b17..e301f28225 100644 --- a/tests/Data/Support/Annotations/DataIterableAnnotationReaderTest.php +++ b/tests/Data/Support/Annotations/DataIterableAnnotationReaderTest.php @@ -2,14 +2,17 @@ declare(strict_types=1); -namespace Hypervel\Tests\Data\Support\Annotations; +namespace Hypervel\Tests\Data\Support\Annotations\DataIterableAnnotationReaderTest; use ArrayIterator; use Countable; use DateTimeImmutable; use Hypervel\Config\Repository; use Hypervel\Container\Container; +use Hypervel\Data\Attributes\DataCollectionOf; +use Hypervel\Data\Contracts\BaseData; use Hypervel\Data\Data; +use Hypervel\Data\DataCollection; use Hypervel\Data\Lazy; use Hypervel\Data\Optional; use Hypervel\Data\Support\Annotations\DataIterableAnnotation; @@ -46,6 +49,221 @@ class DataIterableAnnotationReaderTest extends TestCase { + // The reader parses annotations and the PHPDoc name resolver resolves their item types, where Spatie's reader + // does both, so the upstream cases assert the resolved item and whether it is data. + + /** + * @param null|array{string, bool} $expected + */ + #[DataProvider('dataPropertyAnnotations')] + public function testCanGetTheDataClassForADataCollectionByAnnotation(string $property, ?array $expected): void + { + $this->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. */ @@ -77,26 +295,6 @@ public function testPropertyAnnotationsAreParsedStructurally(): void )); } - /** - * 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. */ @@ -230,6 +428,61 @@ protected function assertAnnotation( $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. */ @@ -289,21 +542,6 @@ class DataIterablePropertyFixture 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'; @@ -551,3 +789,217 @@ class CollectionWithoutDocBlock extends 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/DataCreatorTest.php b/tests/Data/Support/Creation/DataCreatorTest.php index 7a68edaf6a..17b8cc4723 100644 --- a/tests/Data/Support/Creation/DataCreatorTest.php +++ b/tests/Data/Support/Creation/DataCreatorTest.php @@ -56,6 +56,7 @@ 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; @@ -1866,15 +1867,15 @@ public function testConfiguredGlobalNormalizersParticipateInConstruction(): void public function testConstructorInputsReceiveRawInputByName(): void { $this->assertSame( - ['prefix' => 'later', 'options' => ['a' => '1'], 'secret' => 'none'], + ['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(['secret' => 'kept']); + ->from([]); - $this->assertSame(['prefix' => 'hooked', 'options' => [], 'secret' => 'kept'], $hooked->received); + $this->assertSame(['prefix' => 'hooked', 'options' => []], $hooked->received); $this->expectException(CannotCreateData::class); $this->expectExceptionMessageIsOrContains('Parameters missing: prefix'); @@ -1882,6 +1883,59 @@ public function testConstructorInputsReceiveRawInputByName(): void 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; @@ -4059,9 +4113,70 @@ class ConstructorInputCreationData extends Data * * @param array $options */ - public function __construct(string $prefix, array $options = [], protected string $secret = 'none') + public function __construct(string $prefix, array $options = []) + { + $this->received = ['prefix' => $prefix, 'options' => $options]; + } +} + +class NonPublicPromotedCreationData extends Data +{ + /** + * Create a fixture with protected and private promoted state. + */ + public function __construct( + public string $name, + protected string $role = 'member', + private bool $trusted = false, + ) { + } + + /** + * Get the non-public promoted state. + * + * @return array{role: string, trusted: bool} + */ + public function state(): array { - $this->received = ['prefix' => $prefix, 'options' => $options, 'secret' => $secret]; + return ['role' => $this->role, 'trusted' => $this->trusted]; + } +} + +#[FailOnUnknownFields] +class StrictNonPublicPromotedCreationData extends Data +{ + /** + * Create a strict fixture with protected promoted state. + */ + public function __construct( + public string $name, + protected string $role = 'member', + ) { + } +} + +class RequiredNonPublicPromotedCreationData extends Data +{ + /** + * Create a fixture whose only non-public promoted parameter is required. + */ + public function __construct( + public string $name, + private string $secret, + ) { + } +} + +class RequiredNonPublicPromotedWithInputCreationData extends Data +{ + /** + * 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, + ) { } } 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/DataClassTest.php b/tests/Data/Support/DataClassTest.php index e9ffddce2c..d4890b4ca0 100644 --- a/tests/Data/Support/DataClassTest.php +++ b/tests/Data/Support/DataClassTest.php @@ -431,17 +431,25 @@ public static function invalidDeclarationProvider(): array public function testConstructorInputsWithoutPublicPropertiesAreRecorded(): void { - foreach ([ - NonPublicPromotedDataFixture::class => ['secret'], - MissingPropertyDataFixture::class => ['source'], - ] as $class => $inputs) { - $metadata = $this->factory()->build(new ReflectionClass($class)); - - $this->assertSame($inputs, $metadata->constructorInputs); - $this->assertSame([], $metadata->properties); - $this->assertNull($metadata->creationRecipe); - $this->assertFalse($metadata->directConstructorInstantiation); - } + $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); } /** @@ -934,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 { /** diff --git a/tests/Data/Support/DataMethodTest.php b/tests/Data/Support/DataMethodTest.php index 2b24d7e1a0..7b09029ca0 100644 --- a/tests/Data/Support/DataMethodTest.php +++ b/tests/Data/Support/DataMethodTest.php @@ -7,55 +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)); } /** @@ -353,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. */ @@ -521,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 4756f52fa4..d3d860b314 100644 --- a/tests/Data/Support/DataParameterTest.php +++ b/tests/Data/Support/DataParameterTest.php @@ -5,16 +5,91 @@ 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. */ 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 a7e39d9f05..5b37125a36 100644 --- a/tests/Data/Support/DataTypeFactoryTest.php +++ b/tests/Data/Support/DataTypeFactoryTest.php @@ -25,6 +25,7 @@ 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; @@ -218,6 +219,22 @@ public function testItemShorthandsApplyToAnyContainerAfterExactMatches(): void ], $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); + } + public function testCollectionClassItemTypesApplyAfterDeclaredItemTypes(): void { // The collection class imports SimpleData by its short name, so it resolves in the collection's own file. @@ -342,6 +359,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. */ @@ -468,6 +545,15 @@ class DataTypeFactoryFixture /** @var Collection|DataTypeFactoryFirstItemData[] */ public array|Collection $shorthandWithExactArm; + /** @var Collection */ + public array|Collection $collectionAnnotationOnUnion; + + /** @var DataTypeFactoryBox */ + public array $boxAnnotationOnArray; + + /** @var Collection|EloquentCollection */ + public array $competingCollectionAnnotationsOnArray; + public SimpleDataCollection $classAnnotatedCollection; #[DataCollectionOf(DataTypeFactoryFirstItemData::class)] @@ -485,6 +571,13 @@ abstract class DataTypeFactoryFirstItemData implements BaseData { } +/** + * @template TValue + */ +class DataTypeFactoryBox +{ +} + abstract class DataTypeFactorySecondItemData implements BaseData { } @@ -543,3 +636,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/Validation/DataValidatorTest.php b/tests/Data/Support/Validation/DataValidatorTest.php index aa9cb9bf96..ecd78617f6 100644 --- a/tests/Data/Support/Validation/DataValidatorTest.php +++ b/tests/Data/Support/Validation/DataValidatorTest.php @@ -4579,7 +4579,7 @@ class ValidatedConstructorInputData extends Data * * @param array $options */ - public function __construct(string $prefix, array $options = [], protected string $secret = 'none') + public function __construct(string $prefix, array $options = [], string $secret = 'none') { $this->received = ['prefix' => $prefix, 'options' => $options, 'secret' => $secret]; } From 3e5b499328db58f7d8c693a02b75a50175556f9a Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:13:04 +0000 Subject: [PATCH 10/14] Report the actual OPcache and JIT status in the Data benchmark header The header read opcache.enable_cli and opcache.jit from the ini settings, which can differ from what is running: OPcache can be loaded but inactive, and the JIT only runs when its mode and buffer allow it. It now reads opcache_get_status(), reporting 0 and disabled when the extension is missing or inactive. Checked with OPcache off, OPcache on with JIT disabled, and OPcache on with tracing JIT. --- tests/Benchmarks/Data/benchmark.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/Benchmarks/Data/benchmark.php b/tests/Benchmarks/Data/benchmark.php index 18d1d5405b..5806a0c612 100644 --- a/tests/Benchmarks/Data/benchmark.php +++ b/tests/Benchmarks/Data/benchmark.php @@ -936,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), @@ -943,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, From 1b925a22e817dfdb74cbb9a4a9c5de448765bb06 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:29:03 +0000 Subject: [PATCH 11/14] Explain the fixed Data engine architecture in the package README --- src/data/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/data/README.md b/src/data/README.md index 4692de1fdd..519b278393 100644 --- a/src/data/README.md +++ b/src/data/README.md @@ -24,7 +24,7 @@ A required property declared outside the constructor that receives no input, and 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. 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 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()`. From db7b4bec27447c1d982f24dfe6d0b025f39bcfab Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 4 Oct 2026 22:33:56 +0000 Subject: [PATCH 12/14] Reconcile Data documentation and resolve mapped validation references Reorganize the guide around common tasks, fill applicable coverage gaps against spatie/laravel-data docs at ce296f22861dc1237ce468754cc7f46d3ac34ad5, and use Laravel-style prose and examples. Preserve Hypervel-specific APIs and update README section links. Resolve validation attribute field references through input-name metadata so conditional rules read the same mapped input as validation. Cover nested and root references, collection paths, custom inferrers and hooks, while leaving literal rule strings unchanged. Validation: 2,136 Data tests and 5,954 assertions pass; composer analyse and composer lint:fix pass. Documentation examples and link, anchor, fence and JSON checks pass. --- src/data/README.md | 4 +- .../Validation/DataValidationCompiler.php | 105 +- .../Support/Validation/RuleDenormalizer.php | 22 +- src/docs/data-objects.md | 1188 ++++++++++++----- .../Validation/FieldReferenceMappingTest.php | 428 ++++++ 5 files changed, 1425 insertions(+), 322 deletions(-) create mode 100644 tests/Data/Support/Validation/FieldReferenceMappingTest.php diff --git a/src/data/README.md b/src/data/README.md index 519b278393..b2f0620971 100644 --- a/src/data/README.md +++ b/src/data/README.md @@ -28,11 +28,11 @@ Spatie's configurable `pipeline()` and custom `DataPipe` classes are not include 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 [partial trees](https://hypervel.org/docs/data-objects#partial-trees). +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 [casts and transformers](https://hypervel.org/docs/data-objects#casts-and-transformers). +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. diff --git a/src/data/src/Support/Validation/DataValidationCompiler.php b/src/data/src/Support/Validation/DataValidationCompiler.php index 31a42d3541..7e1eef687e 100644 --- a/src/data/src/Support/Validation/DataValidationCompiler.php +++ b/src/data/src/Support/Validation/DataValidationCompiler.php @@ -5,6 +5,7 @@ namespace Hypervel\Data\Support\Validation; use BackedEnum; +use Closure; use DateTimeInterface; use Hypervel\Contracts\Container\Container; use Hypervel\Data\Attributes\Validation\ArrayType; @@ -31,6 +32,7 @@ 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; @@ -90,6 +92,7 @@ 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; @@ -99,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()); @@ -145,6 +149,7 @@ public function compileCollection( $accumulator, $lifecycleDeclarations, $compileUnknownFields, + null, ); $this->appendStructuralMarkers($accumulator, $state->payload()); @@ -218,9 +223,17 @@ 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. @@ -291,7 +304,7 @@ protected function compileNode( $declaredRules = []; foreach ($this->declaredRuleAttributes($property) as $attribute) { - array_push($declaredRules, ...$this->ruleDenormalizer->execute($attribute, $path)); + array_push($declaredRules, ...$this->ruleDenormalizer->execute($attribute, $path, $resolveField)); } if ($declaredRules !== []) { @@ -321,6 +334,7 @@ protected function compileNode( $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. @@ -359,6 +373,7 @@ protected function compileNode( $accumulator, $lifecycleDeclarations, $compileUnknownFields, + $rootDataClass, observed: false, ); } @@ -378,6 +393,7 @@ protected function compileNode( $accumulator, $lifecycleDeclarations, $compileUnknownFields, + $rootDataClass, ); } finally { $state->leave(); @@ -397,6 +413,7 @@ protected function compileNode( $accumulator, $lifecycleDeclarations, $compileUnknownFields, + $rootDataClass, ); continue; @@ -419,6 +436,7 @@ protected function compileNode( $structuralPath, $accumulator, $observed, + $resolveField, ); if ($state->context->mode !== CreationMode::Rules) { @@ -451,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)); @@ -465,6 +484,7 @@ protected function compileDataIterable( $accumulator, $lifecycleDeclarations, $compileUnknownFields, + $rootDataClass, ); } finally { $state->leave(); @@ -487,6 +507,7 @@ protected function compileDataIterableValues( ValidationAccumulator $accumulator, array &$lifecycleDeclarations, bool $compileUnknownFields, + ?DataClass $rootDataClass, ): void { // A finished object, or input Fill could not read, is checked at its own item path. $hasNonArrayValues = false; @@ -508,6 +529,7 @@ protected function compileDataIterableValues( $accumulator, $lifecycleDeclarations, $compileUnknownFields, + $rootDataClass, observed: false, ); @@ -532,6 +554,7 @@ protected function compileDataIterableValues( $accumulator, $lifecycleDeclarations, $compileUnknownFields, + $rootDataClass, ); return; @@ -548,6 +571,7 @@ protected function compileDataIterableValues( $structuralPath, $lifecycleDeclarations, $compileUnknownFields, + $rootDataClass, ); if ($speculative !== null) { @@ -590,6 +614,7 @@ protected function compileDataIterableValues( $itemAccumulator, $lifecycleDeclarations, $compileUnknownFields, + $rootDataClass, ); } finally { $state->leave(); @@ -614,6 +639,7 @@ protected function compileUniformDynamicIterable( ValidationPath $structuralPath, array &$lifecycleDeclarations, bool $compileUnknownFields, + ?DataClass $rootDataClass, ): ?ValidationAccumulator { $compiled = null; @@ -630,6 +656,7 @@ protected function compileUniformDynamicIterable( $itemAccumulator, $lifecycleDeclarations, $compileUnknownFields, + $rootDataClass, ); } finally { $state->leave(); @@ -654,6 +681,7 @@ protected function compileUniformDynamicIterable( * * @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( @@ -667,6 +695,7 @@ protected function propertyRules( string|Enum|null $typeRule, bool $receivesDataCollection, bool &$inferredRequired, + Closure $resolveField, ): array { $attributes = $this->declaredRuleAttributes($property); $generatedRules = null; @@ -691,7 +720,7 @@ protected function propertyRules( $hasPresenceRule = $hasPresenceRule || in_array($attribute::keyword(), self::PRESENCE_RULES, true); } else { - $evaluatedRules[spl_object_id($attribute)] = $denormalizedRules = $this->ruleDenormalizer->execute($attribute, $nodePath); + $evaluatedRules[spl_object_id($attribute)] = $denormalizedRules = $this->ruleDenormalizer->execute($attribute, $nodePath, $resolveField); $hasPresenceRule = $hasPresenceRule || $this->hasPresenceRule($denormalizedRules); } } @@ -707,7 +736,7 @@ protected function propertyRules( foreach ($propertyRules->all() as $rule) { array_push( $rules, - ...($evaluatedRules[spl_object_id($rule)] ?? $this->ruleDenormalizer->execute($rule, $nodePath)), + ...($evaluatedRules[spl_object_id($rule)] ?? $this->ruleDenormalizer->execute($rule, $nodePath, $resolveField)), ); } } else { @@ -716,7 +745,7 @@ protected function propertyRules( $hasEnumAttribute = false; foreach ($attributes as $attribute) { - $denormalizedRules = $this->ruleDenormalizer->execute($attribute, $nodePath); + $denormalizedRules = $this->ruleDenormalizer->execute($attribute, $nodePath, $resolveField); $hasPresenceRule = $hasPresenceRule || $attribute instanceof RequiringRule || $this->hasPresenceRule($denormalizedRules); @@ -727,7 +756,7 @@ protected function propertyRules( 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), + $this->ruleDenormalizer->execute($generatedRules, $nodePath, $resolveField), $attributeRules, ); } else { @@ -768,6 +797,7 @@ protected function propertyRules( $rules = $this->ruleDenormalizer->execute( $hook($rules, $property, $propertyPath, $value), $nodePath, + $resolveField, ); } @@ -897,6 +927,8 @@ protected function resolveRuleInferrers(): array /** * Apply class-owned rule replacements in PHP property-name space. + * + * @param Closure(FieldReference, ValidationPath): string $resolveField */ protected function applyClassRules( DataClass $dataClass, @@ -906,6 +938,7 @@ protected function applyClassRules( ValidationPath $structuralPath, ValidationAccumulator $accumulator, bool $observed, + Closure $resolveField, ): void { if (! $dataClass->hasLifecycleMethod('rules')) { return; @@ -930,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(); @@ -1122,6 +1155,64 @@ 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. * diff --git a/src/data/src/Support/Validation/RuleDenormalizer.php b/src/data/src/Support/Validation/RuleDenormalizer.php index 120dd3890c..0b499bda41 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,9 +20,10 @@ 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); @@ -31,14 +33,14 @@ public function execute(mixed $rule, ValidationPath $path): array $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 +54,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 +63,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 +103,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 +130,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 +146,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/docs/data-objects.md b/src/docs/data-objects.md index 656c2d2688..2fc8a05ebb 100644 --- a/src/docs/data-objects.md +++ b/src/docs/data-objects.md @@ -1,37 +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) @@ -42,157 +60,86 @@ 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. + +## Getting Started -Choose the base class that provides the behavior your object needs. Since `Dto` does not transform values, nested or collected DTOs remain objects when a surrounding `Data` object is transformed. Use `Data` or `Resource` when nested values should also be transformed. - -For trusted internal values that only need typed construction and array or JSON output, consider a [lightweight data object](#lightweight-data-objects). - - -## Lightweight Data Objects - -The `Hypervel\Support\DataObject` class provides a small mapper for internal message envelopes, per-item value objects, and other trusted values used in performance-sensitive code. It does not provide validation, property mapping, lazy properties, partials, resources, or persistence. Use `Data`, `Dto`, or `Resource` when you need those features. - -To define a lightweight data object, extend `DataObject` and promote every constructor parameter as a public property: +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', ]); -``` -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, - ]; -} +$user->age; // 39 +$user->toArray(); ``` -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. +Data objects can also [validate incoming requests](#creating-from-requests), [represent Eloquent models](#creating-from-models), and be [returned directly from controllers](#http-resources). -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`. + +### Generating Data Classes -An `array` property retains its input items as-is during construction. Convert a one-off list explicitly: +Generate a class with the `make:data` command: -```php -$items = array_map(ItemData::from(...), $payload['items']); +```shell +php artisan make:data User ``` -Use Hypervel Data and `DataCollection` when a reusable typed collection needs validation, mapping, transformation controls, or response behavior. - - -## Creating Data Objects - -Extend one of the base classes and define the object's public properties: - -```php - +### Choosing a Base Class -```php - +## Creating Data Objects -A plain constructor parameter without a matching public property receives the input value with the same name, unchanged. Name mapping, casts, and inferred validation rules do not apply to it, but rules you declare for it in `rules()` do, and creation and validation hooks may change it. Eloquent models do not supply these values. A `protected` or `private` promoted property is the object's own state, so input never sets it: it keeps its default value, and creation fails when it has none, unless a `beforeCreation` hook or a named factory supplies it. +Data objects are ordinary PHP objects, so you may construct one directly when you already have values of the correct types: ```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' => ' laravel ']); +$user = new UserData('Taylor Otwell', 39, 'taylor@example.com'); ``` @@ -226,6 +173,85 @@ Use `optional` when the whole object may be absent. It returns `null` when no pa $user = UserData::optional($payload); ``` + +### Creating From Requests + +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: + +```php +use App\Data\UserData; +use App\Models\User; +use Hypervel\Http\RedirectResponse; + +public function store(UserData $data): RedirectResponse +{ + User::create($data->toArray()); + + return redirect()->route('users.index'); +} +``` + +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. + +You may also pass a request explicitly: + +```php +$data = UserData::from($request); +``` + +Under the default validation strategy, passing `$request->all()` instead passes an array and does not trigger validation. Use `validateAndCreate()` when an array needs validation. + + +### Creating From Models + +Pass an Eloquent model to `from` to read its attributes, including model casts and accessors: + +```php +$data = UserData::from(User::findOrFail($id)); +``` + +By default, camel-case properties read the matching snake-case model attributes, so `createdAt` reads `created_at` without a mapper. + +Loaded relationships can become nested data objects or typed collections. For example, an album with its songs may be represented as follows: + +```php +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 @@ -249,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. To construct from the FormRequest's validated result instead, pass `$request->validated()` directly to `UserData::from()` or enable the [`FormRequestNormalizer`](#casts-and-transformers). +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; @@ -272,15 +298,33 @@ 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. -When a class should receive `null` instead of `Optional` for some creations, call `withoutOptionalValues()` on its factory. A missing `string|null|Optional` property then receives `null`, while a missing `string|Optional` property still receives `Optional` because it cannot hold `null`: +`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 -$user = UserData::factory() - ->withoutOptionalValues() - ->from($payload); +PatchUserData::from(['phone' => null])->toArray(); + +// ['phone' => null, 'locale' => 'en'] ``` -A public property declared outside the constructor keeps any value the constructor assigned to it when its input is missing or `Optional`; supplied input, including `null`, replaces it. Validation infers its rules from the declaration alone, so when requests may omit such a property, give it a nullable or `Optional` type or add `#[Sometimes]`. +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 @@ -303,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( @@ -314,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 @@ -337,7 +421,12 @@ class UserData extends Data } ``` -Named methods may receive dependencies from the service container as well as a `CreationContext`. A named method must return the requested data object, and Hypervel uses it directly without validating, casting, or running creation hooks for it again. This also applies to requests, so a named method that returns the finished object from a request is responsible for validating it. The data class's authorization still runs first. To convert other input into the shape Hypervel reads, use a [normalizer](#casts-and-transformers) or [`prepareForPipeline()`](#preparing-input) instead. 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. +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. @@ -360,7 +449,7 @@ class SongMetadataData extends Data } } -class SongData extends Data +class RecordedSongData extends Data { public function __construct( public string $title, @@ -376,7 +465,7 @@ class SongData extends Data } } -$song = SongData::from([ +$song = RecordedSongData::from([ 'title' => 'Never Gonna Give You Up', 'year' => '1987', 'producer' => 'Stock Aitken Waterman', @@ -388,15 +477,9 @@ The method receives each payload separately. When a factory has `prepareData` ho ### Property Name Conversion -Input and output names are unchanged by default. Use mapping attributes when the wire format differs from the PHP property name: +Input and output names are unchanged by default. Use mapping attributes when an input or output key differs from the PHP property name: ```php -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. Validated input must use the mapped input path, which is where its rules apply. Without validation, the PHP property name is read when the mapped input is missing. 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. `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 @@ -487,17 +665,17 @@ Validation uses the rules of the selected type, so a string given to `string|Son 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; @@ -514,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'] ``` @@ -540,14 +725,6 @@ class EventData extends Data Nested data objects are created recursively: ```php -`, 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. 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. - -### Backed Enums + +### Abstract Data Objects -Backed enums are resolved from their backing values: +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( @@ -662,26 +874,34 @@ 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. - -A cast on a nested data object or data collection property receives the input before Hypervel builds any nested objects, so it may build them itself. Without validation, the cast receives the value exactly as it was given. When the object is validated, the value is first prepared and checked against the rules of its declared types, as it would be without the cast: models, objects, and collections become the arrays they would be built from, and the cast receives the validated arrays. A value that only the cast or a named method could interpret, such as a string for a nested data object, therefore fails validation. When every cast returns `Uncastable`, Hypervel builds the nested objects from the validated input. Named methods and constructors beneath the cast run only then, and a named method still receives the value it matched. + +### Custom Casts -A cast's `cast` method receives the property, its input value, the object's declared property values keyed by property name, and the `CreationContext`. The context holds the options for the whole creation, so its `dataClass` is the class the creation started with. Undeclared input, contextual constructor values, and computed properties are not included. Properties that come earlier in the class have already been cast. Later properties contain their supplied input, or the default, `Optional`, or `null` value the object will receive when their input is absent. A value the constructor will assign to a property outside the constructor is not known yet: +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; @@ -692,15 +912,112 @@ class MoneyCast implements Cast { public function cast(DataProperty $property, mixed $value, array $properties, CreationContext $context): Money { - return new Money($value, $properties['currency']); + 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. + +#### 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] ``` -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. A cast registered for a type in a factory or the configuration also casts the items of iterables declared with that item type when it implements `IterableItemCast`. +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. +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 @@ -709,8 +1026,6 @@ By default, a form request is read like any other request, and the data object v ], ``` -During a single creation or transformation, Hypervel may reuse the same cast, transformer, or normalizer instance for every matching value. Do not store per-value state on the extension object itself. - ## Validation @@ -727,32 +1042,20 @@ $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. 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. - -To adjust the rules of every data property, add your own rule inferrers to the `data.rule_inferrers` configuration option. An inferrer implements `Hypervel\Data\RuleInferrers\RuleInferrer`. After Hypervel's own inference, it receives the property, its rules, and a `ValidationContext`: +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\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)); - } +use Hypervel\Data\Support\Creation\ValidationStrategy; - return $rules; - } -} +'validation_strategy' => ValidationStrategy::Always->value, ``` -Validation attributes take precedence over inferred rules of the same type. Rules written in a `Rule` attribute, such as `#[Rule('required|string')]`, count as the matching attributes, so they replace inferred rules too and inferrers see them by type. Inferrers are resolved from the container for each validation, so they may depend on scoped services. + +### 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`. @@ -766,21 +1069,32 @@ Validation attributes mirror Hypervel's validation rules: ```php use Hypervel\Data\Attributes\Validation\Email; use Hypervel\Data\Attributes\Validation\Max; -use Hypervel\Data\Attributes\Validation\Required; class UserData extends Data { public function __construct( - #[Required, Max(100)] + #[Max(100)] public string $name, - #[Required, Email] + #[Email] public string $email, ) { } } ``` -Database-aware `Exists` and `Unique` attributes support the familiar fluent constraints. Use a `FieldReference` to refer to another input field rather than interpolating its name into a string. +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: @@ -807,14 +1121,30 @@ class SongData extends Data 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 Attribute; +use Hypervel\Data\Attributes\Validation\CustomValidationAttribute; +use Hypervel\Data\Support\Validation\ValidationPath; + +#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_PARAMETER)] +class Slug extends CustomValidationAttribute +{ + public function getRules(ValidationPath $path): array + { + return ['alpha_dash', 'max:100']; + } +} +``` + ### 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 { @@ -824,63 +1154,100 @@ public static function rules(ValidationContext $context): array } ``` -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 merge instead. Property keys use PHP property names; Hypervel translates them to the mapped input paths. +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. - - -### Creation Factories +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')`. -The `factory` method returns a fluent factory for creating data objects: +For references from a nested object to the root input, use `FieldReference`: ```php -$user = UserData::factory() - ->alwaysValidate() - ->prepareData(fn (array $data): array => [ - ...$data, - 'source' => 'import', - ]) - ->withValidator(fn (Validator $validator) => $validator->after($check)) - ->from($payload); +use Hypervel\Data\Attributes\Validation\RequiredIf; +use Hypervel\Data\Support\Validation\References\FieldReference; + +#[RequiredIf(new FieldReference('requiresAddress', fromRoot: true), true)] +public ?string $street; ``` -Within one operation, you may reuse a factory to avoid repeating creation setup for every payload: + +### Authorizing Requests + +Define `authorize()` to decide whether a request may create the data object. Dependencies are resolved from the container: ```php -$factory = UserData::factory(); +use Hypervel\Contracts\Auth\Access\Gate; -$users = array_map( - fn (array $payload): UserData => $factory->from($payload), - $payloads, -); +public static function authorize(Gate $gate): bool +{ + return $gate->allows('create', User::class); +} ``` -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. +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. -Factories may change the validation strategy, enable or disable name mapping, named factories, and [Optional values](#defaults-null-and-optional-values), ignore selected named methods, and add casts or normalizers. They also provide the following hooks, which run in this order: + +### Customizing the Validator -1. `prepareData` -2. `beforeValidation` -3. `beforeRules` -4. `afterRules` -5. `withValidator` -6. `afterValidation` -7. `beforeCreation` -8. `afterCreation` +Define `messages()` and `attributes()` to customize errors and field labels: -The `prepareData`, `beforeCreation`, and `afterCreation` hooks run even when validation is skipped. The other hooks run while generating rules or validating, as appropriate. The `beforeValidation` hook receives the complete input and may add fields for the rules to read, while `afterValidation` receives the validated payload. Call `alwaysValidate()` when validation hooks should also apply to an array, model, JSON value, or another non-request source. +```php +public static function messages(): array +{ + return ['email.required' => 'Please provide an email address.']; +} + +public static function attributes(): array +{ + return ['email' => 'email address']; +} +``` -The `beforeCreation` hook 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. +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: -Each call to `factory()` returns a new factory. Keep a reused factory scoped to the current operation instead of storing it across requests. +```php +use Hypervel\Validation\Validator; + +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.'); + } + }, + ]; +} +``` -A cast or named method that creates another data object may pass its `CreationContext` to `factory()` so the new object uses the same options: +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. + +#### Custom Rule Inferrers + +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 -return AddressData::factory($context)->from($value); +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; + } +} ``` -The validation strategy, name mapping, named-method settings, casts, and normalizers are copied. Hooks are not, because they belong to the original creation. +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 @@ -897,10 +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 @@ -926,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. `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 @@ -945,7 +1370,23 @@ 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. +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. + +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. @@ -956,7 +1397,7 @@ Selections owned by nested objects and collection items are composed with select `#[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 @@ -967,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 @@ -975,25 +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. A paginator target requires a Hypervel paginator source because arrays and collections have no pagination details to keep. 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 @@ -1006,35 +1460,67 @@ return UserData::from($user); return UserData::collect($users, DataCollection::class); ``` -Responses use Hypervel's JSON resources. Use `wrap()` or `withoutWrapping()` on an object or collection, or define a `defaultWrap()` method returning the key on the class. You may also define the default wrapper using the `data.wrap` configuration option. + +### Wrapping + +Responses use Hypervel's JSON resources. To wrap the output in a `data` key, call `wrap`: + +```php +return UserData::from($user)->wrap('data'); + +// {"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. -A paginated collection, or a paginator property, is always wrapped, using `data` when no wrapper is set. Its `links` and `meta` keys describe the current page, and responses, `toArray()`, and `toJson()` all produce the same shape: + +### 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": [...], + "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": 10, - "last_page_url": "https://example.com/users?page=10", - "next_page_url": "https://example.com/users?page=2", + "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": 15, - "total": 150 + "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. -Override static `jsonOptions()` or `withResponse(Request $request, JsonResponse $response)` for Laravel-style response customization. A custom data collection class may also override `withResponse()`, while its JSON options come from its data class. Responses use the `200` status code for every request method, so set a `201` status for a newly created object yourself: + +### 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; @@ -1042,26 +1528,38 @@ use Hypervel\Http\Request; public function withResponse(Request $request, JsonResponse $response): void { - if ($request->isMethod('POST')) { - $response->setStatusCode(201); - } + $response->headers->set('X-API-Version', '1'); } ``` -Query-string `include`, `exclude`, `only`, and `except` selections are disabled unless the data class allows them through `allowedRequestIncludes()`, `allowedRequestExcludes()`, `allowedRequestOnly()`, or `allowedRequestExcept()`. +Responses default to `200` for every request method. Set `201` when an action actually creates something: - -## Form Request Casting +```php +return UserData::from($user)->toResponse($request)->setStatusCode(201); +``` -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()`: + +### 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 -namespace App\Http\Requests; +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; @@ -1083,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. @@ -1106,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. A value cast to an abstract data class is stored with the class name of its concrete subtype, unless the abstract class selects its subtype through `PropertyMorphableData::morph()`. Registering aliases stores short names instead, which stay valid when classes are renamed: + +### 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; @@ -1143,14 +1664,16 @@ 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. 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). @@ -1175,7 +1698,11 @@ class UpdatePostData extends Data } ``` -When the object is validated, a promoted property's contextual value is resolved before validation and checked by the property's rules like any other value, so other rules that reference it see the resolved value. Without validation, it is resolved when the object is constructed. Either way, it takes precedence over input and creation and validation hooks, including when the resolved value is `null`, and is converted to its declared type like other input, so a `'123'` route parameter becomes an `int` and an array becomes a nested data object. Input supplied for a promoted contextual property is allowed by strict unknown-field validation but is ignored. When a rule such as `exclude_if` excludes the value, the property is treated as missing. 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. + +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. @@ -1185,19 +1712,29 @@ When the object is validated, a promoted property's contextual value is resolved 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 @@ -1207,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; @@ -1228,24 +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 artisan make:data User +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', +]); ``` -By default, the class is placed in the `App\Data` namespace and suffixed with `Data`, so this command creates `App\Data\UserData`. A name that already ends with the suffix 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. +In this example, `MessageType` is a string-backed enum and `MessagePayload` is another lightweight data object. -For a single class, pass `--suffix` to use a different suffix or `--target-namespace` to choose the complete namespace: +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`. -```shell -php artisan make:data Post --suffix=Dto --target-namespace="App\DataTransferObjects" +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']); ``` -Like Hypervel's other generators, the command 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 @@ -1254,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. By default, this dump format is only used in the `local` and `testing` environments. Set the `data.var_dumper_caster_mode` configuration option to `enabled` to use it in every environment, or `disabled` to dump the complete object. - -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/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; +} From 9b8fe6adfc048c1236bdc722be9673e11e06ebd6 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 4 Oct 2026 23:36:47 +0000 Subject: [PATCH 13/14] Fix Data rule splitting, nested partials and annotation fallbacks Rule strings were kept whole whenever they contained "regex:" anywhere, so a string such as "required|not_regex:/^a/" reached the rule factory as one unknown rule. The normalizer and denormalizer now keep a string whole only when it starts with regex: or not_regex:, since that pattern may contain a pipe. Other strings split on pipes like Laravel's rule parser. A child path's own terminal wildcard now counts as continuing the partial selection, so "artist.*" next to "*" still checks the nested names under artist. Merging two partial trees keeps the nested property lists of both trees instead of recomputing them from the merged result. Contextual parameter resolution skips variadic parameters. An override is passed to the constructor as a single argument, so a resolved list for a variadic parameter would arrive as one nested array; the container already spreads the contextual value when it builds the class. resolveContextualParameters() is now part of the container contract, and the Data instantiator depends on the contract again. When a property declares several collection item annotations, the fallback item type is chosen by comparing the resolved types. An imported name and a fully qualified name for the same item class now agree, so array properties keep their Data item type. Smaller changes: - The test migration uses an unsigned big integer for fake_model_id, matching the referenced key. - The removeType() docblock explains how requiring rules are matched. - The container reference comment explains why an unresolvable binding fails. - The Data creation hooks documentation states that values returned by the hooks are treated as prepared input. - The container documentation notes that variadic parameters are resolved when the class is built. - docs/todo.md records the planned removal of pipe-separated rule strings in favor of rule arrays. Validation: formatting, static analysis, the Data and Container test suites, the facade docblock test and the route dependency resolver test pass. --- docs/todo.md | 1 + src/container/src/Container.php | 4 ++- src/contracts/src/Container/Container.php | 15 +++++++++++ .../src/Support/Creation/DataInstantiator.php | 2 +- .../src/Support/Factories/DataTypeFactory.php | 17 +++++++++++-- .../Support/Transformation/PartialTree.php | 25 +++---------------- .../src/Support/Validation/PropertyRules.php | 4 ++- .../References/ContainerReference.php | 2 +- .../Support/Validation/RuleDenormalizer.php | 5 +++- .../src/Support/Validation/RuleNormalizer.php | 6 +++-- src/docs/container.md | 2 +- src/docs/data-objects.md | 2 +- .../ContextualAttributeBindingTest.php | 24 ++++++++++++++++++ .../2022_02_12_145756_create_fake_tables.php | 2 +- tests/Data/Support/DataTypeFactoryTest.php | 8 ++++++ .../Transformation/PartialTreeTest.php | 9 +++++++ .../Validation/RuleDenormalizerTest.php | 4 +++ .../Support/Validation/RuleNormalizerTest.php | 8 ++++++ 18 files changed, 107 insertions(+), 33 deletions(-) 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/src/container/src/Container.php b/src/container/src/Container.php index 0c63f80d7b..332240ddf2 100755 --- a/src/container/src/Container.php +++ b/src/container/src/Container.php @@ -1761,7 +1761,8 @@ public function buildWith(Closure|string $concrete, array $parameters = []): mix * * 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. + * 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 @@ -1780,6 +1781,7 @@ public function resolveContextualParameters(string $concrete, ?array $names = nu foreach ($recipe->parameters as $paramRecipe) { if ($paramRecipe->contextualAttribute === null + || $paramRecipe->isVariadic || ($names !== null && ! in_array($paramRecipe->name, $names, true)) ) { continue; 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/src/Support/Creation/DataInstantiator.php b/src/data/src/Support/Creation/DataInstantiator.php index 62237a50a4..9849c4a3c3 100644 --- a/src/data/src/Support/Creation/DataInstantiator.php +++ b/src/data/src/Support/Creation/DataInstantiator.php @@ -4,7 +4,7 @@ namespace Hypervel\Data\Support\Creation; -use Hypervel\Container\Container; +use Hypervel\Contracts\Container\Container; use Hypervel\Data\Contracts\BaseData; use Hypervel\Data\Exceptions\CannotCreateData; use Hypervel\Data\Optional; diff --git a/src/data/src/Support/Factories/DataTypeFactory.php b/src/data/src/Support/Factories/DataTypeFactory.php index 447220a062..8e66fd64e9 100644 --- a/src/data/src/Support/Factories/DataTypeFactory.php +++ b/src/data/src/Support/Factories/DataTypeFactory.php @@ -385,13 +385,26 @@ protected function matchingAnnotation( $containerKind = $this->kindFor($container); if ($containerKind->isNonDataIterable() || $containerKind->isDataCollectable()) { - $itemFallbacks[(string) $annotation->itemType] ??= $annotation; + $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 ?? $shorthandFallback - ?? (count($itemFallbacks) === 1 ? array_values($itemFallbacks)[0] : null); + ?? (count($itemFallbacks) === 1 ? $itemFallbacks[0][1] : null); } /** diff --git a/src/data/src/Support/Transformation/PartialTree.php b/src/data/src/Support/Transformation/PartialTree.php index 060a8cccf9..6b81282b2c 100644 --- a/src/data/src/Support/Transformation/PartialTree.php +++ b/src/data/src/Support/Transformation/PartialTree.php @@ -88,8 +88,6 @@ public function merge(?self $other): self } $children = []; - $all = $this->all || $other->all; - $nestedProperties = []; foreach ($this->children as $property => $child) { $children[$property] = $child->merge($other->child($property)); @@ -99,17 +97,11 @@ public function merge(?self $other): self $children[$property] ??= $child->merge($this->child($property)); } - foreach ($children as $property => $child) { - if (self::continuesBeneath($child, $all)) { - $nestedProperties[] = $property; - } - } - return new self( $this->selected || $other->selected, - $all, + $this->all || $other->all, $children, - $nestedProperties, + array_values(array_unique([...$this->nestedProperties, ...$other->nestedProperties])), ); } @@ -232,21 +224,12 @@ private static function hydrate(array $tree, bool $inheritedAll = false): self foreach ($tree['children'] as $property => $child) { $children[$property] = self::hydrate($child, $all); - if (self::continuesBeneath($children[$property], $all)) { + // A child's own terminal * continues the selection; one inherited from its parent does not. + if ($child['children'] !== [] || $child['all']) { $nestedProperties[] = $property; } } return new self($tree['selected'], $all, $children, $nestedProperties); } - - /** - * Determine if a child's selection continues into the child's own value. - * - * A child's own terminal * continues the selection; one inherited from its parent does not. - */ - private static function continuesBeneath(self $child, bool $parentAll): bool - { - return $child->children !== [] || ($child->all && ! $parentAll); - } } diff --git a/src/data/src/Support/Validation/PropertyRules.php b/src/data/src/Support/Validation/PropertyRules.php index c87775504a..e1ca0ae12b 100644 --- a/src/data/src/Support/Validation/PropertyRules.php +++ b/src/data/src/Support/Validation/PropertyRules.php @@ -53,7 +53,9 @@ public function prepend(ValidationRule ...$rules): static } /** - * Remove rules of the given types. Any requiring rule removes every requiring rule. + * 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 diff --git a/src/data/src/Support/Validation/References/ContainerReference.php b/src/data/src/Support/Validation/References/ContainerReference.php index ab95501400..10be024b16 100644 --- a/src/data/src/Support/Validation/References/ContainerReference.php +++ b/src/data/src/Support/Validation/References/ContainerReference.php @@ -25,7 +25,7 @@ public function __construct( */ public function getValue(): mixed { - // An unresolvable binding must fail; a null rule parameter would make database constraints check the wrong rows. + // 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) { diff --git a/src/data/src/Support/Validation/RuleDenormalizer.php b/src/data/src/Support/Validation/RuleDenormalizer.php index 0b499bda41..d61a3c3573 100644 --- a/src/data/src/Support/Validation/RuleDenormalizer.php +++ b/src/data/src/Support/Validation/RuleDenormalizer.php @@ -26,7 +26,10 @@ class RuleDenormalizer 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. + return str_starts_with($rule, 'regex:') || str_starts_with($rule, 'not_regex:') + ? [$rule] + : explode('|', $rule); } if (is_array($rule)) { diff --git a/src/data/src/Support/Validation/RuleNormalizer.php b/src/data/src/Support/Validation/RuleNormalizer.php index 6cc7669aa5..778f5a0037 100644 --- a/src/data/src/Support/Validation/RuleNormalizer.php +++ b/src/data/src/Support/Validation/RuleNormalizer.php @@ -22,7 +22,6 @@ use Hypervel\Data\Exceptions\CannotBuildValidationRule; use Hypervel\Data\Exceptions\CouldNotCreateValidationRule; use Hypervel\Support\Arr; -use Hypervel\Support\Str; use Hypervel\Validation\ConditionalRules; use Hypervel\Validation\Rules\Dimensions as DimensionsRule; use Hypervel\Validation\Rules\Enum as EnumRule; @@ -117,7 +116,10 @@ protected function resolveStringRule(string $rule): array { $rules = []; - $subRules = 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. + $subRules = str_starts_with($rule, 'regex:') || str_starts_with($rule, 'not_regex:') + ? [$rule] + : explode('|', $rule); foreach ($subRules as $subRule) { try { $rules[] = $this->ruleFactory->create($subRule); diff --git a/src/docs/container.md b/src/docs/container.md index 06f29fdd60..de2ea6617c 100644 --- a/src/docs/container.md +++ b/src/docs/container.md @@ -800,7 +800,7 @@ Nested constructor dependencies are still resolved through the container, so the `buildWith` is the right choice when a class needs parameter overrides and must not be cached, for example a builder object or a class whose constructor captures per-call state. Internally, Hypervel uses this method to instantiate view components so each render gets a fresh instance even though the component class has no explicit binding. -Packages that construct objects themselves sometimes need to change a [contextual attribute](#contextual-attributes) value before the constructor receives it. The `resolveContextualParameters` method resolves the values of a class's contextual attribute parameters, keyed by parameter name, without constructing the class. Contextual bindings for the class and attribute callbacks apply as they would during construction. Pass the values to `buildWith` so they are not resolved again: +Packages that construct objects themselves sometimes need to change a [contextual attribute](#contextual-attributes) value before the constructor receives it. The `resolveContextualParameters` method resolves the values of a class's contextual attribute parameters, keyed by parameter name, without constructing the class. Contextual bindings for the class and attribute callbacks apply as they would during construction. Variadic parameters are skipped and resolved when the class is built. Pass the values to `buildWith` so they are not resolved again: ```php use App\Enums\ReportStatus; diff --git a/src/docs/data-objects.md b/src/docs/data-objects.md index 2fc8a05ebb..1fbb3e7768 100644 --- a/src/docs/data-objects.md +++ b/src/docs/data-objects.md @@ -596,7 +596,7 @@ The available hooks run in the following order. Each receives the arguments show 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. `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. +`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. diff --git a/tests/Container/ContextualAttributeBindingTest.php b/tests/Container/ContextualAttributeBindingTest.php index a471089401..b514f16374 100644 --- a/tests/Container/ContextualAttributeBindingTest.php +++ b/tests/Container/ContextualAttributeBindingTest.php @@ -194,6 +194,17 @@ function () use (&$callbacks): void { $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; @@ -870,6 +881,19 @@ public function __construct( } } +class ContainerTestHasVariadicContextualDependency +{ + public array $names; + + /** + * Create a new test fixture. + */ + public function __construct(#[Config('names')] string ...$names) + { + $this->names = $names; + } +} + final class ContainerTestHasConfigValueWithResolvePropertyAndAfterCallback { /** 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 index b92062b2ac..3dbabd1842 100644 --- 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 @@ -29,7 +29,7 @@ public function up(): void $table->string('nullable')->nullable(); $table->string('date'); - $table->bigInteger('fake_model_id')->nullable(); + $table->unsignedBigInteger('fake_model_id')->nullable(); $table->foreign('fake_model_id')->references('id')->on('fake_models')->cascadeOnDelete(); $table->timestamps(); diff --git a/tests/Data/Support/DataTypeFactoryTest.php b/tests/Data/Support/DataTypeFactoryTest.php index 5b37125a36..71e635e930 100644 --- a/tests/Data/Support/DataTypeFactoryTest.php +++ b/tests/Data/Support/DataTypeFactoryTest.php @@ -233,6 +233,11 @@ public function testAnotherCollectionsAnnotationGivesTheItemsOnlyWhenUnambiguous ], $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 @@ -554,6 +559,9 @@ class DataTypeFactoryFixture /** @var Collection|EloquentCollection */ public array $competingCollectionAnnotationsOnArray; + /** @var Collection|EloquentCollection */ + public array $sameItemCollectionAnnotationsOnArray; + public SimpleDataCollection $classAnnotatedCollection; #[DataCollectionOf(DataTypeFactoryFirstItemData::class)] diff --git a/tests/Data/Support/Transformation/PartialTreeTest.php b/tests/Data/Support/Transformation/PartialTreeTest.php index 0d7361d73c..d7f571adab 100644 --- a/tests/Data/Support/Transformation/PartialTreeTest.php +++ b/tests/Data/Support/Transformation/PartialTreeTest.php @@ -115,6 +115,7 @@ public function testRetainsExactSelectionAndPropagatingWildcardProvenance(): voi // 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'); @@ -162,6 +163,14 @@ 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); + } } /** diff --git a/tests/Data/Support/Validation/RuleDenormalizerTest.php b/tests/Data/Support/Validation/RuleDenormalizerTest.php index 01e766f6e7..fe69867403 100644 --- a/tests/Data/Support/Validation/RuleDenormalizerTest.php +++ b/tests/Data/Support/Validation/RuleDenormalizerTest.php @@ -47,6 +47,10 @@ 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 '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']]; diff --git a/tests/Data/Support/Validation/RuleNormalizerTest.php b/tests/Data/Support/Validation/RuleNormalizerTest.php index 92f46e2a30..e66e0d77d2 100644 --- a/tests/Data/Support/Validation/RuleNormalizerTest.php +++ b/tests/Data/Support/Validation/RuleNormalizerTest.php @@ -15,6 +15,7 @@ use Hypervel\Data\Attributes\Validation\ExcludeIf; use Hypervel\Data\Attributes\Validation\Exists; use Hypervel\Data\Attributes\Validation\Min; +use Hypervel\Data\Attributes\Validation\NotRegex; use Hypervel\Data\Attributes\Validation\Regex; use Hypervel\Data\Attributes\Validation\Required; use Hypervel\Data\Attributes\Validation\Rule; @@ -63,6 +64,13 @@ 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 testCanMapMultipleRules(): void { $this->assertEquals([new Required, new Min(0)], $this->mapper->execute(['required', 'min:0'])); From 036bb01573b102c4fa6134eb92522dd957f76695 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 5 Oct 2026 00:02:18 +0000 Subject: [PATCH 14/14] Fix regex rule names and numeric partial segments The previous change kept a rule string whole only when it started with "regex:" or "not_regex:". Laravel's rule parser also treats "notregex" as a regex rule and compares rule names without regard to case, so "notregex:/a|b/" was split on its pipe, and "Regex:/a|b/" had been split before that change too. The normalizer and denormalizer now compare the lowercased first rule name against the names Laravel uses. Numeric partial path segments are stored as integer keys, which lets "meta.0" select an array item. Code that assumed string keys threw a TypeError instead: - merging two partial trees passed an integer key to child(); - the nested partial check passed an integer name to the missing property exception; - the request query string resolver passed an integer field to findProperty(), so a request such as "?only=0" caused a server error. child() and findProperty() accept integer keys, the nested property list holds strings, and the tree docblocks describe integer keys. A numeric segment where a data property is expected follows the usual rules for unknown properties, and the query string resolver treats it as an unknown name. Validation: formatting, static analysis and the Data test suite pass. --- .../Http/RequestQueryStringPartialsResolver.php | 2 +- .../src/Support/Transformation/PartialTree.php | 10 +++++----- .../src/Support/Validation/RuleDenormalizer.php | 3 ++- .../src/Support/Validation/RuleNormalizer.php | 3 ++- .../RequestQueryStringPartialsResolverTest.php | 16 ++++++++++++++++ .../Support/Transformation/PartialTreeTest.php | 14 ++++++++++++++ .../Support/Validation/RuleDenormalizerTest.php | 2 ++ .../Support/Validation/RuleNormalizerTest.php | 6 ++++++ 8 files changed, 48 insertions(+), 8 deletions(-) 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/Support/Transformation/PartialTree.php b/src/data/src/Support/Transformation/PartialTree.php index 6b81282b2c..317f147fc0 100644 --- a/src/data/src/Support/Transformation/PartialTree.php +++ b/src/data/src/Support/Transformation/PartialTree.php @@ -11,7 +11,7 @@ /** * 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( @@ -63,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]; @@ -182,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 @@ -213,7 +213,7 @@ 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 { @@ -226,7 +226,7 @@ private static function hydrate(array $tree, bool $inheritedAll = false): self // A child's own terminal * continues the selection; one inherited from its parent does not. if ($child['children'] !== [] || $child['all']) { - $nestedProperties[] = $property; + $nestedProperties[] = (string) $property; } } diff --git a/src/data/src/Support/Validation/RuleDenormalizer.php b/src/data/src/Support/Validation/RuleDenormalizer.php index d61a3c3573..76f9a2a7cc 100644 --- a/src/data/src/Support/Validation/RuleDenormalizer.php +++ b/src/data/src/Support/Validation/RuleDenormalizer.php @@ -27,7 +27,8 @@ public function execute(mixed $rule, ValidationPath $path, ?Closure $resolveFiel { if (is_string($rule)) { // A regex may contain |, so a string starting with one is a single rule; other strings split like Laravel's. - return str_starts_with($rule, 'regex:') || str_starts_with($rule, 'not_regex:') + // The names match Laravel's ValidationRuleParser::ruleIsRegex(). + return in_array(strtolower(explode(':', $rule, 2)[0]), ['regex', 'not_regex', 'notregex'], true) ? [$rule] : explode('|', $rule); } diff --git a/src/data/src/Support/Validation/RuleNormalizer.php b/src/data/src/Support/Validation/RuleNormalizer.php index 778f5a0037..acf777e0e4 100644 --- a/src/data/src/Support/Validation/RuleNormalizer.php +++ b/src/data/src/Support/Validation/RuleNormalizer.php @@ -117,7 +117,8 @@ 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. - $subRules = str_starts_with($rule, 'regex:') || str_starts_with($rule, 'not_regex:') + // 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) { 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/Support/Transformation/PartialTreeTest.php b/tests/Data/Support/Transformation/PartialTreeTest.php index d7f571adab..09eaeab136 100644 --- a/tests/Data/Support/Transformation/PartialTreeTest.php +++ b/tests/Data/Support/Transformation/PartialTreeTest.php @@ -173,6 +173,20 @@ public function testMergesWildcardAndExplicitSelectionsInEitherOrder(): void } } + /** + * 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)); + } + /** * Test an empty definition avoids allocating a tree. */ diff --git a/tests/Data/Support/Validation/RuleDenormalizerTest.php b/tests/Data/Support/Validation/RuleDenormalizerTest.php index fe69867403..bba63d1ff0 100644 --- a/tests/Data/Support/Validation/RuleDenormalizerTest.php +++ b/tests/Data/Support/Validation/RuleDenormalizerTest.php @@ -49,6 +49,8 @@ public static function rules(): iterable 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']]; diff --git a/tests/Data/Support/Validation/RuleNormalizerTest.php b/tests/Data/Support/Validation/RuleNormalizerTest.php index e66e0d77d2..a5c00fc3dd 100644 --- a/tests/Data/Support/Validation/RuleNormalizerTest.php +++ b/tests/Data/Support/Validation/RuleNormalizerTest.php @@ -71,6 +71,12 @@ public function testSplitsRulesBeforeARegex(): void $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']));