diff --git a/src/Casts/Cast.php b/src/Casts/Cast.php index 028241be..2a452a87 100644 --- a/src/Casts/Cast.php +++ b/src/Casts/Cast.php @@ -7,4 +7,6 @@ interface Cast { public function cast(DataProperty $property, mixed $value, array $context): mixed; + + public function preValidationRules(DataProperty $property, mixed $value): array; } diff --git a/src/Casts/DateTimeInterfaceCast.php b/src/Casts/DateTimeInterfaceCast.php index 575308e4..8579f428 100644 --- a/src/Casts/DateTimeInterfaceCast.php +++ b/src/Casts/DateTimeInterfaceCast.php @@ -46,4 +46,9 @@ public function cast(DataProperty $property, mixed $value, array $context): Date return $datetime; } + + public function preValidationRules(DataProperty $property, mixed $value): array + { + + } } diff --git a/src/Casts/EnumCast.php b/src/Casts/EnumCast.php index bd0c1b64..7fd92a00 100644 --- a/src/Casts/EnumCast.php +++ b/src/Casts/EnumCast.php @@ -29,4 +29,9 @@ public function cast(DataProperty $property, mixed $value, array $context): Back throw CannotCastEnum::create($type, $value); } } + + public function preValidationRules(DataProperty $property, mixed $value): array + { + + } } diff --git a/src/Concerns/BaseData.php b/src/Concerns/BaseData.php index 4ccf6e7a..80311aeb 100644 --- a/src/Concerns/BaseData.php +++ b/src/Concerns/BaseData.php @@ -15,6 +15,7 @@ use Spatie\LaravelData\DataPipes\CastPropertiesDataPipe; use Spatie\LaravelData\DataPipes\DefaultValuesDataPipe; use Spatie\LaravelData\DataPipes\FillRouteParameterPropertiesDataPipe; +use Spatie\LaravelData\DataPipes\InitialValidationDataPipe; use Spatie\LaravelData\DataPipes\MapPropertiesDataPipe; use Spatie\LaravelData\DataPipes\ValidatePropertiesDataPipe; use Spatie\LaravelData\PaginatedDataCollection; @@ -86,8 +87,9 @@ public static function pipeline(): DataPipeline ->through(AuthorizedDataPipe::class) ->through(MapPropertiesDataPipe::class) ->through(FillRouteParameterPropertiesDataPipe::class) - ->through(ValidatePropertiesDataPipe::class) ->through(DefaultValuesDataPipe::class) + ->through(ValidatePropertiesDataPipe::class) +// ->through(InitialValidationDataPipe::class) ->through(CastPropertiesDataPipe::class); } diff --git a/src/DataPipes/FillRouteParameterPropertiesDataPipe.php b/src/DataPipes/FillRouteParameterPropertiesDataPipe.php index 9ac1c422..ebe24ae7 100644 --- a/src/DataPipes/FillRouteParameterPropertiesDataPipe.php +++ b/src/DataPipes/FillRouteParameterPropertiesDataPipe.php @@ -60,6 +60,5 @@ protected function resolveValue( } return data_get($parameter, $attribute->property ?? $dataProperty->name); - ; } } diff --git a/src/DataPipes/InitialValidationDataPipe.php b/src/DataPipes/InitialValidationDataPipe.php new file mode 100644 index 00000000..f7c09a1a --- /dev/null +++ b/src/DataPipes/InitialValidationDataPipe.php @@ -0,0 +1,24 @@ +properties as $property) { + $rules[$property->name] = new InitialPropertyRule($property); + } + + Validator::make($properties->toArray(), $rules)->validate(); + + return $properties; + } +} diff --git a/src/Rules/InitialPropertyRule.php b/src/Rules/InitialPropertyRule.php new file mode 100644 index 00000000..8505f5fe --- /dev/null +++ b/src/Rules/InitialPropertyRule.php @@ -0,0 +1,157 @@ +property->type; + $type = $dataType->type; + + if ($dataType->isMixed() || $type instanceof UndefinedType) { + return; + } + + if (! $dataType->isNullable() && $value === null) { + $fail(__('validation.required', ['attribute' => $attribute])); + } + + if ($dataType->isNullable() && $value === null) { + return; + } + + if ($dataType->type instanceof SingleType) { + $this->validateSingleType($dataType->type, $attribute, $value, $fail); + + return; + } + + if ($dataType->type instanceof MultiType) { + $this->validateMultiType($dataType->type, $attribute, $value, $fail); + + return; + } + + throw new Exception('Unexpected path, we should never get here'); + } + + protected function validateSingleType( + SingleType $singleType, + string $attribute, + mixed $value, + Closure $fail, + ): void { + $validity = $this->isPartialTypeValid($singleType->type, $attribute, $value); + + if ($validity === true) { + return; + } + + $fail($validity); + } + + protected function validateMultiType( + MultiType $multiType, + string $attribute, + mixed $value, + Closure $fail, + ): void { + $invalidMessages = []; + + foreach ($multiType->types as $type) { + $validity = $this->isPartialTypeValid($type, $attribute, $value); + + if ($validity === true) { + return; + } + + $invalidMessages[] = $validity; + } + + $fail(implode(' or ', $invalidMessages)); + } + + protected function isPartialTypeValid( + PartialType $type, + string $attribute, + mixed $value + ): true|string { + // TODO: casts always have precedence over here + if ($this->property->cast) { + return Validator::make( + [$attribute => $value], + [$attribute => $this->property->cast->preValidationRules($this->property, $value)] + )->validate(); + } + + if ($type->name === 'string' && ! $this->isValidString($value)) { + return __('validation.string', ['attribute' => $attribute]); + } + + if ($type->name === 'bool' && ! $this->isValidBool($value)) { + return __('validation.boolean', ['attribute' => $attribute]); + } + + if ($type->name === 'int' && ! $this->isValidIntOrFloat($value)) { + return __('validation.integer', ['attribute' => $attribute]); + } + + if ($type->name === 'float' && ! $this->isValidIntOrFloat($value)) { + return __('validation.float', ['attribute' => $attribute]); + } + + if ($type->name === 'array' && ! $this->isValidArray($value)) { + return __('validation.array', ['attribute' => $attribute]); + } + + // We're having an object over here, this should basically be catched by the casts + // Unless it is a data object + // Data objects can be created using magic methods, so we need check these types and count them all up + // Data objects also can be created using from which accepts all values based upon their normalizer + // Then there are datacollectables, these we can also automatically create + // Again magic collect methods which can take any type + // + some default types + // For each item in such a collection we need to, again check the data objects + + return true; + } + + protected function isValidString(mixed $value): bool + { + return is_scalar($value) || $value instanceof Stringable; + } + + protected function isValidBool(mixed $value): bool + { + return is_scalar($value); + } + + protected function isValidIntOrFloat(mixed $value): bool + { + return is_numeric($value) || is_bool($value); + } + + protected function isValidArray(mixed $value): bool + { + return is_array($value); // TODO what if we have an arrayble? A) cast before validation, B) split casting and decide a cast before validation + // Follow up -> add a new pipe, auto infer casts, which automatically adds global casts + // We'll add support for a collection global cast from array which solves our problem + } +} diff --git a/src/Support/TransientProperty.php b/src/Support/TransientProperty.php new file mode 100644 index 00000000..6e046cbc --- /dev/null +++ b/src/Support/TransientProperty.php @@ -0,0 +1,18 @@ + see idea to set this in stone on the data property + public bool $isExactValue = false, // Whether an object is exaxctly what the property needed, + public bool $noCastFound = false, // Whether no cast was found for the property + public ?DataMethod $magicMethod = null, // A determined magic method that can be used to create the value + ) { + } +} diff --git a/tests/DataPipes/InitialValidationDataPipeTest.php b/tests/DataPipes/InitialValidationDataPipeTest.php new file mode 100644 index 00000000..34611678 --- /dev/null +++ b/tests/DataPipes/InitialValidationDataPipeTest.php @@ -0,0 +1,47 @@ +toBeInstanceOf(DataTestBreakableValidation::class); +}); diff --git a/tests/Fakes/Castables/SimpleCastable.php b/tests/Fakes/Castables/SimpleCastable.php index 417d4919..e9b783c8 100644 --- a/tests/Fakes/Castables/SimpleCastable.php +++ b/tests/Fakes/Castables/SimpleCastable.php @@ -19,6 +19,11 @@ public function cast(DataProperty $property, mixed $value, array $context): mixe { return new SimpleCastable($value); } + + public function preValidationRules(DataProperty $property, mixed $value): array + { + + } }; } } diff --git a/tests/Fakes/Casts/ConfidentialDataCast.php b/tests/Fakes/Casts/ConfidentialDataCast.php index 55fd1c58..3d082221 100644 --- a/tests/Fakes/Casts/ConfidentialDataCast.php +++ b/tests/Fakes/Casts/ConfidentialDataCast.php @@ -12,4 +12,9 @@ public function cast(DataProperty $property, mixed $value, array $context): Simp { return SimpleData::from('CONFIDENTIAL'); } + + public function preValidationRules(DataProperty $property, mixed $value): array + { + + } } diff --git a/tests/Fakes/Casts/ConfidentialDataCollectionCast.php b/tests/Fakes/Casts/ConfidentialDataCollectionCast.php index bfbbcb23..ce9868cd 100644 --- a/tests/Fakes/Casts/ConfidentialDataCollectionCast.php +++ b/tests/Fakes/Casts/ConfidentialDataCollectionCast.php @@ -12,4 +12,9 @@ public function cast(DataProperty $property, mixed $value, array $context): arra { return array_map(fn () => SimpleData::from('CONFIDENTIAL'), $value); } + + public function preValidationRules(DataProperty $property, mixed $value): array + { + + } } diff --git a/tests/Fakes/Casts/ContextAwareCast.php b/tests/Fakes/Casts/ContextAwareCast.php index 6f12b21a..2e6b505d 100644 --- a/tests/Fakes/Casts/ContextAwareCast.php +++ b/tests/Fakes/Casts/ContextAwareCast.php @@ -11,4 +11,9 @@ public function cast(DataProperty $property, mixed $value, array $context): mixe { return $value . '+' . json_encode($context); } + + public function preValidationRules(DataProperty $property, mixed $value): array + { + + } } diff --git a/tests/Fakes/Casts/StringToUpperCast.php b/tests/Fakes/Casts/StringToUpperCast.php index a38ab487..5e848152 100644 --- a/tests/Fakes/Casts/StringToUpperCast.php +++ b/tests/Fakes/Casts/StringToUpperCast.php @@ -11,4 +11,9 @@ public function cast(DataProperty $property, mixed $value, array $context): stri { return strtoupper($value); } + + public function preValidationRules(DataProperty $property, mixed $value): array + { + + } } diff --git a/tests/Rules/InitialPropertyRuleTest.php b/tests/Rules/InitialPropertyRuleTest.php new file mode 100644 index 00000000..b7b98a29 --- /dev/null +++ b/tests/Rules/InitialPropertyRuleTest.php @@ -0,0 +1,101 @@ +assertOk(['string' => 'Hello World']) + ->assertOk(['string' => 3.14]) + ->assertOk(['string' => 42]) + ->assertOk(['string' => true]) + ->assertErrors(['string' => ['array']], ['string' => [__('validation.string', ['attribute' => 'string'])]]); +}); + +it('can pre validate bools', function () { + $class = new class () { + public bool $bool; + }; + + DataPreValidationAsserter::for($class) + ->assertOk(['bool' => '1']) + ->assertOk(['bool' => 0]) + ->assertOk(['bool' => 1.0]) + ->assertOk(['bool' => true]) + ->assertErrors(['bool' => ['1']], ['bool' => [__('validation.boolean', ['attribute' => 'bool'])]]); +}); + +it('can pre validate ints', function () { + $class = new class () { + public int $int; + }; + + DataPreValidationAsserter::for($class) + ->assertOk(['int' => '42']) + ->assertOk(['int' => 3.14]) + ->assertOk(['int' => 42]) + ->assertOk(['int' => true]) + ->assertErrors(['int' => 'not an int'], ['int' => [__('validation.integer', ['attribute' => 'int'])]]) + ->assertErrors(['int' => ['array']], ['int' => [__('validation.integer', ['attribute' => 'int'])]]); +}); + +it('can pre validate floats', function () { + $class = new class () { + public float $float; + }; + + DataPreValidationAsserter::for($class) + ->assertOk(['float' => '3.14']) + ->assertOk(['float' => 3.14]) + ->assertOk(['float' => 42]) + ->assertOk(['float' => true]) + ->assertErrors(['float' => 'not an float'], ['float' => [__('validation.float', ['attribute' => 'float'])]]) + ->assertErrors(['float' => ['array']], ['float' => [__('validation.float', ['attribute' => 'float'])]]); +}); + +it('can pre validate arrays', function () { + $class = new class () { + public array $array; + }; + + DataPreValidationAsserter::for($class) + ->assertOk(['array' => ['array']]) + ->assertErrors(['array' => 'not an array'], ['array' => [__('validation.array', ['attribute' => 'array'])]]); +}); + +it('can pre validate nullable types', function () { + $class = new class () { + public ?string $nullable; + }; + + DataPreValidationAsserter::for($class) + ->assertOk(['nullable' => 'nullable']) + ->assertOk(['nullable' => null]) + ->assertErrors(['nullable' => ['not a string']], ['nullable' => [__('validation.string', ['attribute' => 'nullable'])]]); +}); + +it('can pre validate union types', function () { + $class = new class () { + public string|array $union; + }; + + DataPreValidationAsserter::for($class) + ->assertOk(['union' => 'Hello World']) + ->assertOk(['union' => 3.14]) + ->assertOk(['union' => 42]) + ->assertOk(['union' => true]) + ->assertOk(['union' => ['array']]) + ->assertErrors(['union' => null], ['union' => [ + __('validation.required', ['attribute' => 'union']), + __('validation.array', ['attribute' => 'union']) . ' or ' . __('validation.string', ['attribute' => 'union']) + ]]); +}); + +// Tests Required +// Test the casting functionality rules diff --git a/tests/TestSupport/DataPreValidationAsserter.php b/tests/TestSupport/DataPreValidationAsserter.php new file mode 100644 index 00000000..f7afa633 --- /dev/null +++ b/tests/TestSupport/DataPreValidationAsserter.php @@ -0,0 +1,80 @@ + $dataClass + */ +class DataPreValidationAsserter +{ + private readonly string $dataClass; + + public static function for( + string|object $dataClass + ): self { + return new self($dataClass); + } + + public function __construct( + string|object $dataClass, + ) { + $this->dataClass = is_object($dataClass) + ? $dataClass::class + : $dataClass; + } + + public function assertOk(array $payload): self + { + $this->executeValidation($payload); + + expect(true)->toBeTrue(); + + return $this; + } + + public function assertErrors( + array $payload, + ?array $errors = null + ): self { + try { + $this->executeValidation($payload); + } catch (ValidationException $exception) { + expect(true)->toBeTrue(); + + if ($errors !== null) { + expect($exception->errors())->toBe($errors); + } + + return $this; + } + + assertTrue(false, 'No validation errors'); + + return $this; + } + + private function executeValidation( + array $payload + ): void { + $dataClass = app(DataConfig::class)->getDataClass($this->dataClass); + + $rules = []; + + foreach ($dataClass->properties as $property) { + $rules[$property->name] = new InitialPropertyRule($property); + } + + Validator::make($payload, $rules)->validate(); + } +}