diff --git a/composer.json b/composer.json index b415c1c..5f263ef 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,7 @@ { "name": "icefox/dto", "type": "library", + "version": "0.0.1", "require": { "laravel/framework": "^11.0", "psr/log": "^3.0", @@ -18,15 +19,12 @@ "license": "GPL-2.0-only", "autoload": { "psr-4": { - "Icefox\\DTO\\": "src/" + "Icefox\\Data\\": "src/" } }, "autoload-dev": { "psr-4": { - "Tests\\": "tests/", - "Workbench\\App\\": "workbench/app/", - "Workbench\\Database\\Factories\\": "workbench/database/factories/", - "Workbench\\Database\\Seeders\\": "workbench/database/seeders/" + "Tests\\": "tests/" } }, "authors": [ diff --git a/src/Attributes/CastWith.php b/src/Attributes/CastWith.php index 6d544c5..25c32cf 100644 --- a/src/Attributes/CastWith.php +++ b/src/Attributes/CastWith.php @@ -2,8 +2,7 @@ declare(strict_types=1); -namespace Icefox\DTO\Attributes; - +namespace Icefox\Data\Attributes; use Attribute; #[Attribute(Attribute::TARGET_PARAMETER)] diff --git a/src/Attributes/Flat.php b/src/Attributes/Flat.php index 27d73ad..2664003 100644 --- a/src/Attributes/Flat.php +++ b/src/Attributes/Flat.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Icefox\DTO\Attributes; +namespace Icefox\Data\Attributes; use Attribute; diff --git a/src/Attributes/FromInput.php b/src/Attributes/FromInput.php index 0a536bc..d43b697 100644 --- a/src/Attributes/FromInput.php +++ b/src/Attributes/FromInput.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Icefox\DTO\Attributes; +namespace Icefox\Data\Attributes; use Attribute; diff --git a/src/Attributes/FromRouteParameter.php b/src/Attributes/FromRouteParameter.php index 4eb11d1..d104e1c 100644 --- a/src/Attributes/FromRouteParameter.php +++ b/src/Attributes/FromRouteParameter.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Icefox\DTO\Attributes; +namespace Icefox\Data\Attributes; use Attribute; diff --git a/src/Attributes/OverwriteRules.php b/src/Attributes/Overwrite.php similarity index 60% rename from src/Attributes/OverwriteRules.php rename to src/Attributes/Overwrite.php index e1cee40..a797cc6 100644 --- a/src/Attributes/OverwriteRules.php +++ b/src/Attributes/Overwrite.php @@ -2,11 +2,9 @@ declare(strict_types=1); -namespace Icefox\DTO\Attributes; +namespace Icefox\Data\Attributes; use Attribute; #[Attribute(Attribute::TARGET_METHOD)] -class OverwriteRules -{ -} +class Overwrite {} diff --git a/src/Config.php b/src/Config.php deleted file mode 100644 index f827157..0000000 --- a/src/Config.php +++ /dev/null @@ -1,63 +0,0 @@ - static::rulesIlluminateCollection(...), - default => null, - }; - } - - - /** - * @return array - */ - private static function rulesIlluminateCollection(ParameterMeta $parameter, RuleFactory $factory): array - { - if (is_null($parameter->tag)) { - return []; - } - - $type = $parameter->tag->getType(); - if (!$type instanceof Generic) { - return []; - } - - $subtypes = $type->getTypes(); - - if (count($subtypes) == 0) { - return ['' => ['array']]; - } - - $subtype = count($subtypes) == 1 ? $subtypes[0] : $subtypes[1]; - - return $factory->mergeRules( - ['' => ['array']], - $factory->getRulesFromDocBlock($subtype, '.*'), - ); - } -} diff --git a/src/Factories/CollectionFactory.php b/src/CustomHandlers.php similarity index 75% rename from src/Factories/CollectionFactory.php rename to src/CustomHandlers.php index bfd0fe0..cf0eb15 100644 --- a/src/Factories/CollectionFactory.php +++ b/src/CustomHandlers.php @@ -1,17 +1,16 @@ */ - public static function rules(ParameterMeta $parameter, RuleFactory $factory): array + public static function CollectionRules(ParameterMeta $parameter, RuleFactory $factory): array { if (is_null($parameter->tag)) { return []; diff --git a/src/DataObject.php b/src/DataObject.php index 95a7235..69b15e5 100644 --- a/src/DataObject.php +++ b/src/DataObject.php @@ -2,13 +2,14 @@ declare(strict_types=1); -namespace Icefox\DTO; +namespace Icefox\Data; +use Icefox\Data\Factories\DataObjectFactory; use Illuminate\Http\Request; trait DataObject { - public static function fromRequest(Request $request): mixed + public static function fromRequest(Request $request): ?static { return DataObjectFactory::fromRequest(static::class, $request); } diff --git a/src/DataObjectFactory.php b/src/DataObjectFactory.php deleted file mode 100644 index adbf200..0000000 --- a/src/DataObjectFactory.php +++ /dev/null @@ -1,81 +0,0 @@ -route() instanceof Route ? $request->route()->parameters() : []; - return static::fromArray($class, $request->input(), $routeParameters); - } - - /** - * @param class-string $class - * @param array $input - * @param array $routeParameters - */ - public static function fromArray(string $class, array $input, array $routeParameters): ?object - { - $logger = new Log(); - $parameters = ReflectionHelper::getParametersMeta($class); - foreach ($parameters as $parameter) { - $parameterName = $parameter->reflection->getName(); - - foreach ($parameter->reflection->getAttributes(FromRouteParameter::class) as $fromRouteParameter) { - if ($value = $routeParameters[$fromRouteParameter->newInstance()->name] ?? null) { - $input[$parameterName] = $value; - continue 2; - } - } - - foreach ($parameter->reflection->getAttributes(FromInput::class) as $attr) { - if ($value = $input[$attr->newInstance()->name] ?? null) { - $input[$parameterName] = $value; - continue 2; - } - } - - if ($value = $input[$parameterName] ?? null) { - $input[$parameterName] = $value; - continue; - } - - // if ($parameter->reflection->isDefaultValueAvailable()) { - // $input[$parameterName] = $parameter->reflection->getDefaultValue(); - // continue; - // } - } - $logger->inputRaw($input); - - $rules = (new RuleFactory($logger))->make($class); - - $validator = method_exists($class, 'withValidator') - ? App::call("$class::withValidator", ['data' => $input, 'rules' => $rules]) - : App::makeWith(Validator::class, ['data' => $input, 'rules' => $rules]); - - if ($validator->fails()) { - $logger->validationErrors($validator->errors()->toArray()); - if (method_exists($class, 'fails')) { - return App::call("$class::fails", ['validator' => $validator ]); - } - throw new ValidationException($validator); - } - - return ValueFactory::make($class, $validator->validated()); - } -} diff --git a/src/Factories/DataObjectFactory.php b/src/Factories/DataObjectFactory.php new file mode 100644 index 0000000..96b3c9c --- /dev/null +++ b/src/Factories/DataObjectFactory.php @@ -0,0 +1,144 @@ +route(); + $routeParameters = $route instanceof Route ? $route->parameters() : []; + return static::fromArray($class, $request->input(), $routeParameters); + } + + /** + * @param class-string $class + * @param array $rawInput + * @param array $routeParameters + */ + public static function fromArray(string $class, array $rawInput, array $routeParameters): ?object + { + $logger = Log::channel('dto'); + + $defaults = method_exists($class, 'defaults') + ? App::call("$class::defaults") + : []; + + $mergedInput = array_replace_recursive($defaults, $rawInput); + $input = self::mapInput($class, $mergedInput, $routeParameters, $logger); + + $rules = (new RuleFactory($logger))->make($class); + + $validator = method_exists($class, 'withValidator') + ? App::call("$class::withValidator", ['data' => $input, 'rules' => $rules]) + : App::makeWith(Validator::class, ['data' => $input, 'rules' => $rules]); + + if ($validator->fails()) { + $logger->warning('validation error', $validator->errors()->toArray()); + if (method_exists($class, 'fails')) { + return App::call("$class::fails", ['validator' => $validator ]); + } + throw new ValidationException($validator); + } + + return ValueFactory::make($class, $validator->validated()); + } + + /** + * @param class-string $class + * @param array $rawInput + * @param array $routeParameters + * @return array + */ + public static function mapInput( + string $class, + array $rawInput, + array $routeParameters, + LoggerInterface $logger, + ): array { + $input = []; + $parameters = ReflectionHelper::getParametersMeta($class); + + foreach ($parameters as $parameter) { + $parameterName = $parameter->reflection->getName(); + + foreach ($parameter->reflection->getAttributes(FromRouteParameter::class) as $fromRouteParameter) { + if ($value = $routeParameters[$fromRouteParameter->newInstance()->name] ?? null) { + $input[$parameterName] = $value; + continue 2; + } + } + + $reflectionType = $parameter->reflection->getType(); + $namedType = $reflectionType instanceof ReflectionNamedType ? $reflectionType->getName() : null; + $annotatedType = $parameter->tag?->getType(); + + $isListType = ReflectionHelper::isListType($parameter); + + foreach ($parameter->reflection->getAttributes(FromInput::class) as $attr) { + if ($value = $rawInput[$attr->newInstance()->name] ?? null) { + if ($valueType = ReflectionHelper::getListParameterValueType($parameter->tag)) { + $input[$parameterName] = $isListType + ? array_map( + fn($element) => self::mapInput($valueType, $element, $routeParameters, $logger), + $value, + ) + : self::mapInput($valueType, $value, $routeParameters, $logger); + } else { + $input[$parameterName] = $value; + } + continue 2; + } + } + + if ($valueType = ReflectionHelper::getListParameterValueType($parameter->tag)) { + if (class_exists($valueType)) { + $input[$parameterName] = $isListType + ? array_map( + fn($element) => self::mapInput($valueType, $element, $routeParameters, $logger), + $rawInput[$parameterName], + ) + : self::mapInput($valueType, $rawInput[$parameterName], $routeParameters, $logger); + continue; + } + if (array_key_exists($parameterName, $rawInput)) { + $input[$parameterName] = $rawInput[$parameterName]; + continue; + } + } + + if ($reflectionType instanceof ReflectionNamedType) { + if ($reflectionType->isBuiltin()) { + if (array_key_exists($parameterName, $rawInput)) { + $input[$parameterName] = $rawInput[$parameterName]; + } + } else { + $input[$parameterName] = self::mapInput($reflectionType->__toString(), $rawInput[$parameterName], $routeParameters, $logger); + } + continue; + } + if (array_key_exists($parameterName, $rawInput)) { + $input[$parameterName] = $rawInput[$parameterName]; + } + } + $logger->debug('input', $input); + return $input; + } +} diff --git a/src/Support/RuleFactory.php b/src/Factories/RuleFactory.php similarity index 90% rename from src/Support/RuleFactory.php rename to src/Factories/RuleFactory.php index 9aa1da5..df1bdd7 100644 --- a/src/Support/RuleFactory.php +++ b/src/Factories/RuleFactory.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace Icefox\DTO\Support; +namespace Icefox\Data\Factories; -use Icefox\DTO\Attributes\Flat; -use Icefox\DTO\Attributes\OverwriteRules; -use Icefox\DTO\Config; -use Icefox\DTO\ParameterMeta; -use Icefox\DTO\ReflectionHelper; +use Icefox\Data\Attributes\Flat; +use Icefox\Data\Attributes\Overwrite; +use Icefox\Data\ParameterMeta; +use Icefox\Data\ReflectionHelper; +use Icefox\Data\Factories\RuleFactory; use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\Log; use Illuminate\Validation\Rule; @@ -29,7 +29,7 @@ use phpDocumentor\Reflection\Types\Object_; final class RuleFactory { /** - * @return array> + * @return array> */ public function getRulesFromDocBlock( Type $type, @@ -62,7 +62,7 @@ final class RuleFactory /** * @param array $parameters - * @return array> + * @return array> */ public function infer(array $parameters, string $basePrefix): array { @@ -79,7 +79,7 @@ final class RuleFactory } /** - * @return array> + * @return array> */ public function buildParameterRule(ParameterMeta $parameter, string $prefix): array { @@ -106,7 +106,7 @@ final class RuleFactory } if ($type instanceof ReflectionNamedType && $name = $type->getName()) { - if ($globalRules = Config::getRules($name)) { + if ($globalRules = config('dto.rules.' . $name, null)) { foreach ($globalRules($parameter, $this) as $scopedPrefix => $values) { $realPrefix = $prefix . $scopedPrefix; $rules[$realPrefix] = array_merge($rules[$realPrefix] ?? [], $values); @@ -149,7 +149,7 @@ final class RuleFactory /** * @param class-string $class - * @return array> + * @return array> */ public function make(string $class): array { @@ -161,7 +161,7 @@ final class RuleFactory $customRules = $hasRulesMethod ? App::call("$class::rules", []) : []; - if ($hasRulesMethod && !empty($classReflection->getMethod('rules')->getAttributes(OverwriteRules::class))) { + if ($hasRulesMethod && !empty($classReflection->getMethod('rules')->getAttributes(Overwrite::class))) { $rules = $customRules; } else { $inferredRules = RuleFactory::infer($parameters, ''); @@ -172,9 +172,9 @@ final class RuleFactory } /** - * @param array> $first - * @param array> $second - * @return array> + * @param array> $first + * @param array> $second + * @return array> */ public function mergeRules(array $first, array $second): array { diff --git a/src/Support/ValueFactory.php b/src/Factories/ValueFactory.php similarity index 75% rename from src/Support/ValueFactory.php rename to src/Factories/ValueFactory.php index 2781ae2..835d7ea 100644 --- a/src/Support/ValueFactory.php +++ b/src/Factories/ValueFactory.php @@ -2,17 +2,13 @@ declare(strict_types=1); -namespace Icefox\DTO\Support; +namespace Icefox\Data\Factories; -use Icefox\DTO\Attributes\CastWith; -use Icefox\DTO\Attributes\Flat; -use Icefox\DTO\Config; -use Icefox\DTO\ParameterMeta; -use Icefox\DTO\ReflectionHelper; +use Icefox\Data\Attributes\CastWith; +use Icefox\Data\Attributes\Flat; +use Icefox\Data\ReflectionHelper; use Illuminate\Support\Facades\App; use ReflectionNamedType; -use ReflectionParameter; -use phpDocumentor\Reflection\DocBlock\Tags\Param; use phpDocumentor\Reflection\PseudoTypes\Generic; use phpDocumentor\Reflection\Type; use phpDocumentor\Reflection\Types\AbstractList; @@ -24,21 +20,6 @@ use phpDocumentor\Reflection\Types\Object_; class ValueFactory { - public static function constructObject(string $className, mixed $rawValue): object - { - if ($mapper = Config::getCaster($className)) { - return $mapper($rawValue); - } - - // Plain values or numeric arrays are passed as a single parameter to the constructor - if (!is_array($rawValue) || array_key_exists(0, $rawValue)) { - return new $className($className); - } - - // Associative arrays leverage Laravel service container - return App::makeWith($className, $rawValue); - } - public static function resolveAnnotatedValue(Type $type, mixed $rawValue): mixed { if ($type instanceof Nullable) { @@ -91,17 +72,24 @@ class ValueFactory public static function resolveDeclaredTypeValue(ReflectionNamedType $parameter, mixed $rawValue): mixed { - return match ($parameter->getName()) { + $type = $parameter->getName(); + if (is_a($type, \BackedEnum::class, true)) { + return $type::from($rawValue); + } + + return match ($type) { 'string' => $rawValue, 'bool' => boolval($rawValue), 'int' => intval($rawValue), 'float' => floatval($rawValue), 'array' => $rawValue, - default => self::make($parameter->getName(), $rawValue), - + default => self::make($type, $rawValue), }; } + /** + * @param array $input + */ public static function make(string $class, array $input): object { $parameters = ReflectionHelper::getParametersMeta($class); @@ -112,9 +100,9 @@ class ValueFactory $parameterArgs = empty($parameter->reflection->getAttributes(Flat::class)) ? ($input[$name] ?? null) : $input; if (is_null($parameterArgs)) { - if ($parameter->reflection->allowsNull()) { - $arguments[$name] = null; - } + $arguments[$name] = $parameter->reflection->isDefaultValueAvailable() + ? $parameter->reflection->getDefaultValue() + : null; continue; } @@ -128,25 +116,27 @@ class ValueFactory $type = $parameter->tag?->getType(); if (empty($parameterClass) && $type instanceof Object_) { - $parameterClass = $type->getFqsen(); + $parameterClass = $type->getFqsen()?->__toString(); } + if (!empty($parameterClass) && $caster = config('dto.cast.' . $parameterClass, null)) { $arguments[$name] = App::call($caster, ['data' => $parameterArgs]); continue; } - if ($parameter->tag instanceof Param) { + if (!is_null($type)) { $arguments[$name] = self::resolveAnnotatedValue($type, $parameterArgs); continue; } - if ($parameter->reflection->getType() instanceof ReflectionNamedType) { - $arguments[$name] = self::resolveDeclaredTypeValue($parameter->reflection->getType(), $parameterArgs); + $reflectionType = $parameter->reflection->getType(); + if ($reflectionType instanceof ReflectionNamedType) { + $arguments[$name] = self::resolveDeclaredTypeValue($reflectionType, $parameterArgs); continue; } $arguments[$name] = $parameterArgs; } - return App::makeWith($class, $arguments); + return new $class(...$arguments); } } diff --git a/src/IData.php b/src/IData.php new file mode 100644 index 0000000..0f2e749 --- /dev/null +++ b/src/IData.php @@ -0,0 +1,5 @@ +reflection->getName(); - - foreach ($parameter->reflection->getAttributes(FromRouteParameter::class) as $attr) { - $map[$name][] = 'route_' . $attr->newInstance()->name; - } - - foreach ($parameter->reflection->getAttributes(FromInput::class) as $attr) { - $map[$name][] = $attr->newInstance()->name; - } - - $map[$name][] = $name; - } - return $map; - } - - private static self $_instance; - - public static function instance(): self - { - if (empty(self::$_instance)) { - self::$_instance = new self(new Log()); - } - return self::$_instance; - } -} diff --git a/src/ParameterMeta.php b/src/ParameterMeta.php index 9d814d1..c63d95f 100644 --- a/src/ParameterMeta.php +++ b/src/ParameterMeta.php @@ -1,6 +1,6 @@ publishes([ + __DIR__ . '../../workbench/config/dto.php' => config_path('dto.php'), + ]); + + $this->app->beforeResolving(function ($abstract, $parameters, $app) { + if ($app->has($abstract)) { + return; + } + if (is_subclass_of($abstract, IData::class)) { + $app->bind($abstract, fn($container) => DataObjectFactory::fromRequest($abstract, $container['request'])); + } + }); + } +} diff --git a/src/ReflectionHelper.php b/src/ReflectionHelper.php index 5c92a22..fa64c03 100644 --- a/src/ReflectionHelper.php +++ b/src/ReflectionHelper.php @@ -1,10 +1,13 @@ getType(); + + if ($type instanceof AbstractList) { + return $type->getValueType()->__toString(); + } + + if (!$type instanceof Generic) { + return null; + } + + $subtypes = $type->getTypes(); + return count($subtypes) > 1 ? $subtypes[1]->__toString() : $subtypes[0]->__toString(); + } + + public static function isListType(ParameterMeta $parameter): bool + { + $reflectionType = $parameter->reflection->getType(); + $namedType = $reflectionType instanceof ReflectionNamedType ? $reflectionType->getName() : null; + $annotatedType = $parameter->tag?->getType(); + return + $parameter->reflection->isArray() + || in_array($namedType, config('dto.listTypes', [])) + || in_array($annotatedType?->__toString(), config('dto.listTypes', [])) + || $annotatedType instanceof AbstractList; + + } + +} diff --git a/tests/Casters/CasterTest.php b/tests/Casters/CasterTest.php deleted file mode 100644 index 93fc5ce..0000000 --- a/tests/Casters/CasterTest.php +++ /dev/null @@ -1,71 +0,0 @@ - []]); - }); - - it('uses CastWith attribute over global config caster', function () { - $globalCaster = function (mixed $data): SimpleValue { - return new SimpleValue($data * 3); - }; - config(['dto.cast.' . SimpleValue::class => $globalCaster]); - - $object = WithSpecificCaster::fromArray([ - 'value' => ['value' => 5], - ]); - - expect($object->value->value)->toBe(10); // 5 * 2 - }); - - it('falls back to global config caster when no CastWith attribute', function () { - $globalCaster = function (mixed $data): SimpleValue { - return new SimpleValue($data['value'] * 3); - }; - config(['dto.cast.' . SimpleValue::class => $globalCaster]); - - $object = WithGlobalCaster::fromArray([ - 'simple' => ['value' => 5], - ]); - - expect($object->simple->value)->toBe(15); // 5 * 3 - }); - - it('falls back to default construction when no caster exists', function () { - $object = WithoutCaster::fromArray([ - 'value' => ['value' => 5], - ]); - expect($object)->toBeInstanceOf(WithoutCaster::class); - }); -}); - -describe('caster with rules', function () { - beforeEach(function () { - config(['dto.cast' => []]); - }); - - it('validates input using caster rules before casting', function () { - expect(fn() => WithSpecificCaster::fromArray([ - 'value' => [], - ]))->toThrow(ValidationException::class); - }); - - it('accepts valid input and casts correctly', function () { - $object = WithSpecificCaster::fromArray([ - 'value' => ['value' => 10], - ]); - - expect($object->value->value)->toBe(20); // 10 * 2 - }); -}); diff --git a/tests/Casters/SimpleValue.php b/tests/Casters/SimpleValue.php deleted file mode 100644 index 1f08683..0000000 --- a/tests/Casters/SimpleValue.php +++ /dev/null @@ -1,10 +0,0 @@ - ['required', 'numeric'], - ]; - } -} diff --git a/tests/Casters/WithGlobalCaster.php b/tests/Casters/WithGlobalCaster.php deleted file mode 100644 index d829310..0000000 --- a/tests/Casters/WithGlobalCaster.php +++ /dev/null @@ -1,16 +0,0 @@ - $values - */ - public function __construct(public array $values) {} -} diff --git a/tests/Classes/CarbonPeriodMapper.php b/tests/Classes/CarbonPeriodMapper.php deleted file mode 100644 index 4edee01..0000000 --- a/tests/Classes/CarbonPeriodMapper.php +++ /dev/null @@ -1,22 +0,0 @@ - ['required', 'date'], - 'end' => ['required', 'date'], - ]; - } -} diff --git a/tests/Classes/CollectionDataObject.php b/tests/Classes/CollectionDataObject.php deleted file mode 100644 index 711dbc7..0000000 --- a/tests/Classes/CollectionDataObject.php +++ /dev/null @@ -1,17 +0,0 @@ - $values - */ - public function __construct(public Collection $values) {} -} diff --git a/tests/Classes/FailsReturnsDefault.php b/tests/Classes/FailsReturnsDefault.php deleted file mode 100644 index 7fdba3a..0000000 --- a/tests/Classes/FailsReturnsDefault.php +++ /dev/null @@ -1,23 +0,0 @@ -json(['errors' => $validator->errors()], 422) - ); - } -} diff --git a/tests/Classes/FromInputObject.php b/tests/Classes/FromInputObject.php deleted file mode 100644 index 575e44c..0000000 --- a/tests/Classes/FromInputObject.php +++ /dev/null @@ -1,19 +0,0 @@ - ['value' => 1 ] ], [], new NullLogger()); + expect($input)->toBe(['element' => ['value' => 1]]); +}); + +readonly class MappedElement +{ + public function __construct(#[FromInput('name')] public int $value) {} +} + +readonly class MappedNode +{ + public function __construct(public MappedElement $element) {} +} + +test('basic nested input map', function () { + $input = DataObjectFactory::mapInput(MappedNode::class, ['element' => ['name' => 1 ] ], [], new NullLogger()); + expect($input)->toBe(['element' => ['value' => 1]]); +}); + +readonly class MappedCollectionItem +{ + public function __construct(#[FromInput('id_item')] public int $idItem) {} +} + +readonly class MappedCollectionRoot +{ + /** + * @param Collection $items + */ + public function __construct(public string $text, #[FromInput('data')] public Collection $items) {} +} + +test('using from input nested', function () { + $mapped = DataObjectFactory::mapInput(MappedCollectionRoot::class, [ + 'text' => 'abc', + 'data' => [ + [ 'id_item' => 1 ], + [ 'id_item' => 2 ], + [ 'id_item' => 4 ], + [ 'id_item' => 8 ], + ], + ], [], new NullLogger()); + + expect($mapped)->toBe([ + 'text' => 'abc', + 'items' => [ + [ 'idItem' => 1 ], + [ 'idItem' => 2 ], + [ 'idItem' => 4 ], + [ 'idItem' => 8 ], + ], + ]); +}); + +readonly class CollectionRoot +{ + /** + * @param Collection $items + */ + public function __construct(public string $text, public Collection $items) {} +} + +test('using from input', function () { + $mapped = DataObjectFactory::mapInput(MappedCollectionRoot::class, [ + 'text' => 'abc', + 'items' => [ + [ 'id_item' => 1 ], + [ 'id_item' => 2 ], + [ 'id_item' => 4 ], + [ 'id_item' => 8 ], + ], + ], [], new NullLogger()); + + expect($mapped)->toBe([ + 'text' => 'abc', + 'items' => [ + [ 'idItem' => 1 ], + [ 'idItem' => 2 ], + [ 'idItem' => 4 ], + [ 'idItem' => 8 ], + ], + ]); +}); + +readonly class AnnotatedArrayItem +{ + public function __construct(#[FromInput('name')] public float $value) {} +} + +readonly class AnnotatedArray +{ + /** + * @param array $items + */ + public function __construct(public array $items) {} +} + +test('annotated array', function () { + $mapped = DataObjectFactory::mapInput( + AnnotatedArray::class, + ['items' => [['name' => 1], ['name' => 2]]], + [], + new NullLogger(), + ); + + expect($mapped)->toBe(['items' => [['value' => 1], ['value' => 2]]]); +}); + +test('route parameter priority over from input', function () { + $dto = new class (123) { + public function __construct( + #[FromRouteParameter('user_id')] + #[FromInput('user_id')] + public int $id, + ) {} + }; + + $result = DataObjectFactory::mapInput( + $dto::class, + ['user_id' => 456], + ['user_id' => 123], + new \Psr\Log\NullLogger(), + ); + + expect($result['id'])->toBe(123); +}); + +test('multiple route parameters', function () { + $dto = new class (45, 89, 'A') { + public function __construct( + #[FromRouteParameter('course_id')] + public int $courseId, + #[FromRouteParameter('student_id')] + public int $studentId, + #[FromInput('grade')] + public string $grade, + ) {} + }; + + $result = DataObjectFactory::mapInput( + $dto::class, + ['grade' => 'A'], + ['course_id' => 45, 'student_id' => 89], + new \Psr\Log\NullLogger(), + ); + + expect($result['courseId'])->toBe(45) + ->and($result['studentId'])->toBe(89) + ->and($result['grade'])->toBe('A'); +}); + +test('route parameter with nested object', function () { + $addressDto = new class ('Main St') { + public function __construct(public string $street) {} + }; + + $dto = new class (123, $addressDto) { + public function __construct( + #[FromRouteParameter('user_id')] + public int $userId, + public $address, + ) {} + }; + + $result = DataObjectFactory::mapInput( + $dto::class, + ['user_id' => 456, 'address' => ['street' => 'Main St']], + ['user_id' => 123], + new \Psr\Log\NullLogger(), + ); + + expect($result['userId'])->toBe(123) + ->and($result['address']['street'])->toBe('Main St'); +}); + +readonly class SimpleWithDefaults +{ + public function __construct( + public string $name, + public int $age, + public string $city = 'Unknown', + ) {} + + /** + * @return array + */ + public static function defaults(): array + { + return [ + 'city' => 'New York', + 'age' => 25, + ]; + } +} + +test('defaults with fromArray - basic usage', function () { + $object = DataObjectFactory::fromArray( + SimpleWithDefaults::class, + ['name' => 'John'], + [], + ); + + expect($object->name)->toBe('John') + ->and($object->age)->toBe(25) + ->and($object->city)->toBe('New York'); +}); + +readonly class NestedWithDefaults +{ + public function __construct( + public string $title, + public SimpleWithDefaults $user, + ) {} + + /** + * @return array> + */ + public static function defaults(): array + { + return [ + 'user' => [ + 'name' => 'Default User', + 'age' => 30, + ], + ]; + } +} + +test('defaults with nested objects', function () { + $object = DataObjectFactory::fromArray( + NestedWithDefaults::class, + ['title' => 'Admin Dashboard'], + [], + ); + + expect($object->title)->toBe('Admin Dashboard') + ->and($object->user->name)->toBe('Default User') + ->and($object->user->age)->toBe(30) + ->and($object->user->city)->toBe('Unknown'); +}); + +test('defaults merged with input - input overrides defaults', function () { + $object = DataObjectFactory::fromArray( + SimpleWithDefaults::class, + ['name' => 'Alice', 'age' => 40, 'city' => 'Los Angeles'], + [], + ); + + expect($object->name)->toBe('Alice') + ->and($object->age)->toBe(40) + ->and($object->city)->toBe('Los Angeles'); +}); + + +test('defaults with simple nested structures', function () { + $object = DataObjectFactory::fromArray( + NestedWithDefaults::class, + ['title' => 'Simple Project'], + [], + ); + + expect($object->title)->toBe('Simple Project') + ->and($object->user->name)->toBe('Default User') + ->and($object->user->age)->toBe(30); +}); + +readonly class ArrayWithDefaults +{ + /** + * @param array $settings + */ + public function __construct( + public string $projectName, + public array $settings, + ) {} + /** + * @return array> + */ + public static function defaults(): array + { + return [ + 'settings' => [ + 'theme' => 'dark', + 'notifications' => true, + 'language' => 'en', + ], + ]; + } +} + +test('defaults with array structures', function () { + $object = DataObjectFactory::fromArray( + ArrayWithDefaults::class, + ['projectName' => 'New Project'], + [], + ); + + expect($object->projectName)->toBe('New Project') + ->and($object->settings)->toBe([ + 'theme' => 'dark', + 'notifications' => true, + 'language' => 'en', + ]); +}); + +test('defaults partially overridden by input', function () { + $object = DataObjectFactory::fromArray( + ArrayWithDefaults::class, + [ + 'projectName' => 'Custom Project', + 'settings' => [ + 'theme' => 'light', + 'language' => 'fr', + ], + ], + [], + ); + + expect($object->projectName)->toBe('Custom Project') + ->and($object->settings)->toBe([ + 'theme' => 'light', + 'notifications' => true, // From defaults + 'language' => 'fr', + ]); +}); + +readonly class WithInputMappingAndDefaults +{ + public function __construct( + #[FromInput('full_name')] + public string $name, + #[FromInput('user_age')] + public int $age, + public string $role = 'user', + ) {} + /** + * @return array + */ + public static function defaults(): array + { + return [ + 'name' => 'Default Name', + 'role' => 'admin', + 'age' => 18, + ]; + } +} + +test('defaults work with FromInput attribute mapping', function () { + $object = DataObjectFactory::fromArray( + WithInputMappingAndDefaults::class, + ['full_name' => 'John Doe'], + [], + ); + + expect($object->name)->toBe('John Doe') // From input mapping + ->and($object->age)->toBe(18) // From defaults + ->and($object->role)->toBe('admin'); // From defaults +}); + +test('array_merge_recursive behavior with nested arrays', function () { + $object = DataObjectFactory::fromArray( + ArrayWithDefaults::class, + [ + 'projectName' => 'Test Project', + 'settings' => [ + 'new_setting' => 'custom_value', + ], + ], + [], + ); + + expect($object->settings)->toBe([ + 'theme' => 'dark', // From defaults + 'notifications' => true, // From defaults + 'language' => 'en', // From defaults + 'new_setting' => 'custom_value', // From input + ]); +}); diff --git a/tests/DataObjectTest.php b/tests/DataObjectTest.php deleted file mode 100644 index b68a4b7..0000000 --- a/tests/DataObjectTest.php +++ /dev/null @@ -1,212 +0,0 @@ -make(PrimitiveData::class); - expect($rules)->toMatchArray([ - 'string' => ['required'], - 'int' => ['required', 'numeric'], - 'float' => ['required', 'numeric'], - 'bool' => ['required', 'boolean'], - ]); - }); - - it('creates object with all required properties', function () { - $object = PrimitiveData::fromArray([ - 'string' => 'abc', - 'int' => 0, - 'float' => 3.14, - 'bool' => true, - ]); - expect($object)->toBeInstanceOf(PrimitiveData::class); - expect($object->string)->toBe('abc'); - expect($object->int)->toBe(0); - expect($object->float)->toEqualWithDelta(3.14, 0.0001); - expect($object->bool)->toBeTrue(); - }); -}); - -describe('optional data', function () { - it('creates optional rules', function () { - $rules = (new RuleFactory(new Log()))->make(OptionalData::class); - expect($rules)->toMatchArray([ - 'string' => ['sometimes'], - 'int' => ['sometimes', 'numeric'], - 'float' => ['sometimes', 'numeric'], - 'bool' => ['sometimes', 'boolean'], - ]); - }); - - it('creates object with default values', function () { - $object = OptionalData::fromArray([]); - expect($object)->toBeInstanceOf(OptionalData::class); - expect($object->string)->toBe('xyz'); - expect($object->int)->toBe(3); - expect($object->float)->toEqualWithDelta(0.777, 0.0001); - expect($object->bool)->toBeFalse(); - }); -}); - -describe('nullable data', function () { - it('creates nullable rules', function () { - $rules = (new RuleFactory(new Log()))->make(OptionalNullableData::class); - expect($rules)->toMatchArray([ - 'string' => ['required'], - 'int' => ['nullable', 'numeric'], - 'float' => ['sometimes', 'numeric'], - 'bool' => ['sometimes', 'boolean'], - ]); - }); - - it('accepts explicit null', function () { - $object = OptionalNullableData::fromArray([ - 'string' => 'ijk', - 'int' => null, - ]); - expect($object)->toBeInstanceOf(OptionalNullableData::class); - expect($object->string)->toBe('ijk'); - expect($object->int)->toBeNull(); - }); - - it('accepts implicit null', function () { - $object = OptionalNullableData::fromArray(['string' => 'dfg']); - expect($object)->toBeInstanceOf(OptionalNullableData::class); - expect($object->string)->toBe('dfg'); - expect($object->int)->toBeNull(); - }); -}); - -describe('reference other DataObject', function () { - - it('creates recursive rules', function () { - $rules = (new RuleFactory(new Log()))->make(RecursiveDataObject::class); - expect($rules)->toMatchArray([ - 'string' => ['required'], - 'extra.string' => ['required'], - 'extra.int' => ['required', 'numeric'], - 'extra.float' => ['required', 'numeric'], - 'extra.bool' => ['required', 'boolean'], - ]); - }); -}); - -describe('primitive array', function () { - it('creates array rules', function () { - $rules = (new RuleFactory(new Log()))->make(ArrayDataObject::class); - expect($rules)->toMatchArray([ - 'values' => ['required', 'array'], - 'values.*' => ['required', 'numeric'], - ]); - }); -}); - - -describe('object array', function () { - it('creates array rules', function () { - $rules = (new RuleFactory(new Log()))->make(CollectionDataObject::class); - expect($rules)->toMatchArray([ - 'values' => ['required', 'array'], - 'values.*' => ['required'], - 'values.*.string' => ['required'], - 'values.*.int' => ['nullable', 'numeric'], - 'values.*.float' => ['sometimes', 'numeric'], - 'values.*.bool' => ['sometimes', 'boolean'], - ]); - }); -}); - -describe('can map input names', function () { - - it('creates rules with property names', function () { - - $rules = (new RuleFactory(new Log()))->make(FromInputObject::class); - expect($rules)->toMatchArray([ - 'text' => ['required' ], - 'standard' => ['required', 'numeric'], - ]); - }); - - it('maps input name', function () { - $object = FromInputObject::fromArray([ - 'other_name' => 'xyz', - 'standard' => 1, - ]); - expect($object->text)->toBe('xyz'); - expect($object->standard)->toBe(1); - }); - - it('prioritizes the mapped input', function () { - $object = FromInputObject::fromArray([ - 'other_name' => 'xyz', - 'text' => 'abc', - 'standard' => 1, - ]); - expect($object->text)->toBe('xyz'); - expect($object->standard)->toBe(1); - }); -}); - -describe('with mapper object', function () { - it('uses mapper', function () { - $object = WithMapperObject::fromArray([ - 'period' => [ - 'start' => '1980-01-01', - 'end' => '1990-01-01', - ], - 'standard' => 1, - ]); - expect($object->period->startsAt('1980-01-01'))->toBeTrue(); - expect($object->period->endsAt('1990-01-01'))->toBeTrue(); - }); - - it('uses mapper as validator', function () { - $object = WithMapperObject::fromArray([ - 'period' => [ - 'end' => '1990-01-01', - ], - 'standard' => 1, - ]); - })->throws(ValidationException::class); -}); - -test('failed validation throws ValidationException', function () { - $object = PrimitiveData::fromArray([ - 'int' => 0, - 'float' => 3.14, - 'bool' => true, - ]); -})->throws(ValidationException::class); - - -test('creates collection', function () { - $object = CollectionDataObject::fromArray([ - 'values' => [ - [ - 'string' => 'x', - 'int' => 1, - 'float' => 3.3, - ], - [ - 'string' => 'y', - 'int' => null, - ], - ], - ]); - expect($object->values->count())->toBe(2); - expect($object->values[0]->string)->toBe('x'); - expect($object->values[1]->int)->toBeNull(); -}); diff --git a/tests/FailedValidation/FailsMethodTest.php b/tests/FailedValidation/FailsMethodTest.php deleted file mode 100644 index 11b2ca1..0000000 --- a/tests/FailedValidation/FailsMethodTest.php +++ /dev/null @@ -1,108 +0,0 @@ - 0, - 'float' => 3.14, - 'bool' => true, - ]); - })->toThrow(ValidationException::class); - }); - - it('returns null when fails() returns null', function () { - $result = FailsReturnsNull::fromArray([ - 'int' => 0, - ]); - - expect($result)->toBeNull(); - }); - - it('returns static instance when fails() returns an object', function () { - $result = FailsReturnsDefault::fromArray([ - 'int' => 0, - ]); - - expect($result)->toBeInstanceOf(FailsReturnsDefault::class); - expect($result->string)->toBe('default_value'); - expect($result->int)->toBe(42); - }); -}); - -describe('HTTP request handling', function () { - - beforeEach(function () { - \Illuminate\Support\Facades\Route::post('/test-validation-exception', function () { - PrimitiveData::fromArray([ - 'int' => 0, - 'float' => 3.14, - 'bool' => true, - ]); - return response()->json(['success' => true]); - }); - - \Illuminate\Support\Facades\Route::post('/test-http-response-exception', function () { - FailsWithHttpResponse::fromArray([ - 'int' => 0, - ]); - return response()->json(['success' => true]); - }); - - \Illuminate\Support\Facades\Route::post('/test-validation-exception-html', function () { - PrimitiveData::fromArray([ - 'int' => 0, - 'float' => 3.14, - 'bool' => true, - ]); - return response('success'); - }); - }); - - it('returns 422 with errors when ValidationException is thrown in JSON request', function () { - $response = $this->postJson('/test-validation-exception', [ - 'int' => 0, - 'float' => 3.14, - 'bool' => true, - ]); - - $response->assertStatus(422); - $response->assertJsonValidationErrors(['string']); - }); - - it('returns custom JSON response when HttpResponseException is thrown', function () { - $response = $this->postJson('/test-http-response-exception', [ - 'int' => 0, - ]); - - $response->assertStatus(422); - $response->assertJsonStructure(['errors']); - $response->assertJsonFragment([ - 'errors' => [ - 'string' => ['The string field is required.'], - ], - ]); - }); - - it('redirects back with session errors when ValidationException is thrown in text/html request', function () { - $response = $this->post('/test-validation-exception-html', [ - 'int' => 0, - 'float' => 3.14, - 'bool' => true, - ], [ - 'Accept' => 'text/html', - ]); - - $response->assertRedirect(); - $response->assertSessionHasErrors(['string']); - }); -}); diff --git a/tests/Http/RequestTest.php b/tests/Http/RequestTest.php new file mode 100644 index 0000000..d0713e3 --- /dev/null +++ b/tests/Http/RequestTest.php @@ -0,0 +1,107 @@ +reply = $message == 'ping' ? 'pong' : 'unknown'; + } +} + +test('injects data object', function () { + Route::post('/', fn(Basic $object) => ['reply' => $object->reply]); + + /** @var \Tests\TestCase $this */ + $this->postJson('/', ['message' => 'ping'])->assertJson(['reply' => 'pong']); +}); + +test('fails on validation error', function () { + Route::post('/', fn(Basic $object) => ['reply' => $object->reply]); + /** @var \Tests\TestCase $this */ + $resp = $this->postJson('/', []) + ->assertStatus(422) + ->assertJson([ + 'message' => 'The message field is required.', + 'errors' => ['message' => ['The message field is required.']], + ]); +}); + +readonly class WithCustomValidator implements IData +{ + public string $reply; + public function __construct(string $message) + { + $this->reply = $message == 'ping' ? 'pong' : 'unknown'; + } + + /** + * @param array $data + * @param array $rules + */ + public static function withValidator(array $data, array $rules): Validator + { + return ValidatorFacade::make($data, $rules, $messages = [ + 'message.required' => 'the known message is pong', + ]); + } +} + +test('replies with custom validator', function () { + Route::post('/', fn(WithCustomValidator $object) => []); + /** @var \Tests\TestCase $this */ + $this->postJson('/', []) + ->assertStatus(422) + ->assertJson([ + 'message' => 'the known message is pong', + 'errors' => ['message' => ['the known message is pong']], + ]); +}); + +readonly class WithCustomFailure implements IData +{ + public function __construct(public bool $flag) {} + + public static function fails(Validator $validator): void + { + throw new HttpResponseException( + response(['result' => 'invalid, but that is ok' ], 202), + ); + } +} + +test('uses custom response', function () { + Route::post('/', fn(WithCustomFailure $object) => response('', 204)); + + /** @var \Tests\TestCase $this */ + $this->postJson('/', []) + ->assertStatus(202) + ->assertJson(['result' => 'invalid, but that is ok']); +}); + +readonly class WithDefaultObjectOnFailure implements IData +{ + public function __construct(public bool $flag) {} + + public static function fails(): self + { + return new self(false); + } +} + +test('uses default object on failure', function () { + Route::post('/', fn(WithDefaultObjectOnFailure $object) => response(['flag' => $object->flag], 200)); + + /** @var \Tests\TestCase $this */ + $this->postJson('/', []) + ->assertStatus(200) + ->assertJson(['flag' => false]); +}); diff --git a/tests/Logging/CustomLogger.php b/tests/Logging/CustomLogger.php deleted file mode 100644 index c4bff3d..0000000 --- a/tests/Logging/CustomLogger.php +++ /dev/null @@ -1,36 +0,0 @@ -logs[] = [ - 'level' => $level, - 'message' => $message, - 'context' => $context, - ]; - } - - public function hasLog(string $level, string $contains): bool - { - foreach ($this->logs as $log) { - if ($log['level'] === $level && str_contains($log['message'], $contains)) { - return true; - } - } - return false; - } - - public function clear(): void - { - $this->logs = []; - } -} diff --git a/tests/Logging/LogTest.php b/tests/Logging/LogTest.php deleted file mode 100644 index 6847938..0000000 --- a/tests/Logging/LogTest.php +++ /dev/null @@ -1,281 +0,0 @@ -set('dto.log.logger', NullLogger::class); - }); - - it('uses NullLogger as fallback when logger config is null', function () { - config()->set('dto.log.logger', null); - $log = new Log(); - expect($log->logger)->toBeInstanceOf(NullLogger::class); - }); - - it('uses NullLogger as fallback when logger config is invalid', function () { - config()->set('dto.log.logger', 'NonExistentLoggerClass'); - $log = new Log(); - expect($log->logger)->toBeInstanceOf(NullLogger::class); - }); - - it('instantiates logger from class name via Laravel container', function () { - config()->set('dto.log.logger', CustomLogger::class); - $log = new Log(); - expect($log->logger)->toBeInstanceOf(CustomLogger::class); - }); - - it('uses logger object directly when provided', function () { - $customLogger = new CustomLogger(); - config()->set('dto.log.logger', $customLogger); - $log = new Log(); - expect($log->logger)->toBe($customLogger); - }); - - it('invokes callable to get logger instance', function () { - config()->set('dto.log.logger', function () { - return new CustomLogger(); - }); - - $log = new Log(); - - expect($log->logger)->toBeInstanceOf(CustomLogger::class); - }); -}); - -describe('log level configuration', function () { - - beforeEach(function () { - $this->customLogger = new CustomLogger(); - config()->set('dto.log.logger', $this->customLogger); - }); - - afterEach(function () { - config()->set('dto.log.logger', NullLogger::class); - config()->set('dto.log.rules', LogLevel::DEBUG); - config()->set('dto.log.input', LogLevel::DEBUG); - config()->set('dto.log.raw_input', LogLevel::DEBUG); - config()->set('dto.log.validation_errors', LogLevel::INFO); - }); - - it('logs rules at configured level', function () { - config()->set('dto.log.rules', LogLevel::INFO); - - $log = new Log(); - $log->rules(['field' => ['required']]); - - expect($this->customLogger->hasLog(LogLevel::INFO, 'field'))->toBeTrue(); - }); - - it('logs input at configured level', function () { - config()->set('dto.log.input', LogLevel::INFO); - - $log = new Log(); - $log->input(['field' => 'value']); - - expect($this->customLogger->hasLog(LogLevel::INFO, 'value'))->toBeTrue(); - }); - - it('logs raw input at configured level', function () { - config()->set('dto.log.raw_input', LogLevel::ERROR); - - $log = new Log(); - $log->inputRaw(['field' => 'raw_value']); - - expect($this->customLogger->hasLog(LogLevel::ERROR, 'raw_value'))->toBeTrue(); - }); - - it('logs validation errors at configured level', function () { - config()->set('dto.log.validation_errors', LogLevel::ERROR); - - $log = new Log(); - $log->validationErrors(['field' => ['The field is required.']]); - - expect($this->customLogger->hasLog(LogLevel::ERROR, 'required'))->toBeTrue(); - }); - - it('allows different log levels for each log type', function () { - config()->set('dto.log.rules', LogLevel::DEBUG); - config()->set('dto.log.input', LogLevel::INFO); - config()->set('dto.log.raw_input', LogLevel::INFO); - - $log = new Log(); - - $log->rules(['rules_field' => ['required']]); - $log->input(['input_field' => 'value']); - $log->inputRaw(['raw_field' => 'raw_value']); - - expect($this->customLogger->hasLog(LogLevel::DEBUG, 'rules_field'))->toBeTrue(); - expect($this->customLogger->hasLog(LogLevel::INFO, 'input_field'))->toBeTrue(); - expect($this->customLogger->hasLog(LogLevel::INFO, 'raw_field'))->toBeTrue(); - }); - - it('defaults to DEBUG level when not configured', function () { - config()->set('dto.log.rules', null); - config()->set('dto.log.input', null); - config()->set('dto.log.raw_input', null); - - $customLogger = new CustomLogger(); - config()->set('dto.log.logger', $customLogger); - - $log = new Log(); - - $log->rules(['field' => ['required']]); - $log->input(['field' => 'value']); - $log->inputRaw(['field' => 'raw_value']); - - expect(count($customLogger->logs))->toBe(3); - expect($customLogger->logs[0]['level'])->toBe(LogLevel::DEBUG); - expect($customLogger->logs[1]['level'])->toBe(LogLevel::DEBUG); - expect($customLogger->logs[2]['level'])->toBe(LogLevel::DEBUG); - }); -}); - -describe('integration with DataObject', function () { - - beforeEach(function () { - $this->customLogger = new CustomLogger(); - config()->set('dto.log.logger', $this->customLogger); - config()->set('dto.log.rules', LogLevel::DEBUG); - config()->set('dto.log.input', LogLevel::DEBUG); - config()->set('dto.log.raw_input', LogLevel::DEBUG); - }); - - afterEach(function () { - config()->set('dto.log.logger', NullLogger::class); - }); - - it('logs raw input during fromArray execution', function () { - PrimitiveData::fromArray([ - 'string' => 'test', - 'int' => 42, - 'float' => 3.14, - 'bool' => true, - ]); - - expect($this->customLogger->hasLog(LogLevel::DEBUG, 'raw_input'))->toBeFalse(); - expect($this->customLogger->hasLog(LogLevel::DEBUG, 'string'))->toBeTrue(); - expect($this->customLogger->hasLog(LogLevel::DEBUG, '42'))->toBeTrue(); - }); - - it('logs rules during fromArray execution', function () { - PrimitiveData::fromArray([ - 'string' => 'test', - 'int' => 42, - 'float' => 3.14, - 'bool' => true, - ]); - - expect($this->customLogger->hasLog(LogLevel::DEBUG, 'required'))->toBeTrue(); - }); - - it('logs processed input during fromArray execution', function () { - PrimitiveData::fromArray([ - 'string' => 'test', - 'int' => 42, - 'float' => 3.14, - 'bool' => true, - ]); - - expect($this->customLogger->hasLog(LogLevel::DEBUG, 'test'))->toBeTrue(); - }); - - it('captures all three log types during successful fromArray', function () { - PrimitiveData::fromArray([ - 'string' => 'integration_test', - 'int' => 123, - 'float' => 9.99, - 'bool' => false, - ]); - - $rawInputLogged = false; - $rulesLogged = false; - $inputLogged = false; - - foreach ($this->customLogger->logs as $log) { - if (str_contains($log['message'], 'string')) { - $rawInputLogged = true; - } - if (str_contains($log['message'], 'required')) { - $rulesLogged = true; - } - if (str_contains($log['message'], 'integration_test')) { - $inputLogged = true; - } - } - - expect($rawInputLogged)->toBeTrue('Raw input should be logged'); - expect($rulesLogged)->toBeTrue('Rules should be logged'); - expect($inputLogged)->toBeTrue('Processed input should be logged'); - }); - - it('logs even when validation fails', function () { - try { - PrimitiveData::fromArray([ - 'int' => 42, - 'float' => 3.14, - 'bool' => true, - ]); - } catch (\Illuminate\Validation\ValidationException $e) { - // Expected - } - - expect($this->customLogger->hasLog(LogLevel::DEBUG, 'required'))->toBeTrue(); - expect($this->customLogger->hasLog(LogLevel::DEBUG, '42'))->toBeTrue(); - }); - - it('logs validation errors when validation fails', function () { - config()->set('dto.log.validation_errors', LogLevel::ERROR); - - try { - PrimitiveData::fromArray([ - 'int' => 42, - 'float' => 3.14, - 'bool' => true, - ]); - } catch (\Illuminate\Validation\ValidationException $e) { - // Expected - } - - expect($this->customLogger->hasLog(LogLevel::ERROR, 'string'))->toBeTrue(); - expect($this->customLogger->hasLog(LogLevel::ERROR, 'required'))->toBeTrue(); - }); -}); - -describe('logging with NullLogger', function () { - - it('does not throw when logging with NullLogger', function () { - config()->set('dto.log.logger', NullLogger::class); - - $log = new Log(); - - expect(function () use ($log) { - $log->rules(['field' => ['required']]); - $log->input(['field' => 'value']); - $log->inputRaw(['field' => 'raw_value']); - })->not->toThrow(\Throwable::class); - }); - - it('does not affect DataObject behavior when using NullLogger', function () { - config()->set('dto.log.logger', NullLogger::class); - - $object = PrimitiveData::fromArray([ - 'string' => 'test', - 'int' => 42, - 'float' => 3.14, - 'bool' => true, - ]); - - expect($object)->toBeInstanceOf(PrimitiveData::class); - expect($object->string)->toBe('test'); - }); -}); diff --git a/tests/Rules/RulesTest.php b/tests/Rules/RulesTest.php index 5f7ed9f..f902d73 100644 --- a/tests/Rules/RulesTest.php +++ b/tests/Rules/RulesTest.php @@ -4,97 +4,208 @@ declare(strict_types=1); namespace Tests\Rules; -use Icefox\DTO\Log; -use Icefox\DTO\ReflectionHelper; -use Icefox\DTO\Support\RuleFactory; -use Illuminate\Validation\ValidationException; -use Tests\Rules\WithEmptyOverwriteRules; -use Tests\Rules\WithMergedRules; -use Tests\Rules\WithOverwriteRules; +use Icefox\Data\Attributes\Flat; +use Icefox\Data\Attributes\Overwrite; +use Icefox\Data\Factories\RuleFactory; +use Illuminate\Support\Collection; -describe('rules array shape', function () { - it('returns inferred rules shape from RuleFactory::infer (inferred only)', function () { - $parameters = ReflectionHelper::getParametersMeta(WithMergedRules::class); - $rules = RuleFactory::infer($parameters, ''); +readonly class BasicPrimitives +{ + public function __construct( + public string $text, + public int $number, + public bool $flag, + public ?array $items, + public float $floating = 0.0, + ) {} +} - expect($rules)->toBe([ - 'value' => ['required', 'numeric'], - ]); - }); - - it('returns inferred rules shape regardless of OverwriteRules attribute', function () { - $parameters = ReflectionHelper::getParametersMeta(WithOverwriteRules::class); - $rules = RuleFactory::infer($parameters, ''); - - expect($rules)->toBe([ - 'value' => ['required', 'numeric'], - ]); - }); +test('required rules', function () { + expect(RuleFactory::instance()->make(BasicPrimitives::class))->toBe([ + 'text' => ['required'], + 'number' => ['required', 'numeric'], + 'flag' => ['required', 'boolean'], + 'items' => ['nullable', 'array'], + 'floating' => ['sometimes', 'numeric'], + ]); }); -describe('getRules method', function () { - it('returns merged rules from DataObject::getRules()', function () { - $rules = (new RuleFactory(new Log()))->make(WithMergedRules::class); - - expect($rules)->toBe([ - 'value' => ['required', 'numeric', 'max:20'], - ]); - }); - - it('returns only custom rules from DataObject::getRules() with OverwriteRules', function () { - $rules = (new RuleFactory(new Log()))->make(WithOverwriteRules::class); - - expect($rules)->toBe([ - 'value' => ['numeric', 'max:20'], - ]); - }); - - it('returns empty rules from DataObject::getRules() with OverwriteRules and no custom rules', function () { - $rules = (new RuleFactory(new Log()))->make(WithEmptyOverwriteRules::class); - - expect($rules)->toBe([]); - }); +readonly class AnnotatedArray +{ + /** + * @param array $items + */ + public function __construct(public array $items) {} +} +test('annotated array', function () { + expect(RuleFactory::instance()->make(AnnotatedArray::class))->toBe([ + 'items' => ['required', 'array'], + 'items.*' => ['required', 'numeric'], + ]); }); -describe('rules merging', function () { - it('merges custom rules with inferred rules by default', function () { - $object = WithMergedRules::fromArray([ - 'value' => 10, - ]); - - expect($object->value)->toBe(10); - }); - - it('fails validation when merged rule is violated', function () { - expect(fn() => WithMergedRules::fromArray([ - 'value' => 25, - ]))->toThrow(ValidationException::class); - }); - - it('fails validation when required rule is violated (inferred)', function () { - expect(fn() => WithMergedRules::fromArray([ - ]))->toThrow(ValidationException::class); - }); +readonly class AnnotatedArrayNullableValue +{ + /** + * @param array $items + */ + public function __construct(public array $items) {} +} +test('annotated array with nullable items', function () { + expect(RuleFactory::instance()->make(AnnotatedArrayNullableValue::class))->toBe([ + 'items' => ['required', 'array'], + 'items.*' => ['nullable', 'numeric'], + ]); }); -describe('rules overwrite', function () { - it('uses only custom rules when OverwriteRules attribute is present', function () { - $object = WithOverwriteRules::fromArray([ - 'value' => 10, - ]); +readonly class PlainLeaf +{ + public function __construct(public string $name) {} +} +readonly class PlainRoot +{ + public function __construct(public int $value, public PlainLeaf $leaf) {} +} - expect($object->value)->toBe(10); - }); - - it('fails validation when custom rule is violated', function () { - expect(fn() => WithOverwriteRules::fromArray([ - 'value' => 25, - ]))->toThrow(ValidationException::class); - }); - - it('does not enforce inferred required rule when overwritten', function () { - $rules = WithOverwriteRules::rules(); - expect($rules)->toHaveKey('value'); - expect($rules['value'])->toBe(['numeric', 'max:20']); - }); +test('plain nesting', function () { + expect(RuleFactory::instance()->make(PlainRoot::class))->toBe([ + 'value' => ['required', 'numeric'], + 'leaf' => ['required'], + 'leaf.name' => ['required'], + ]); +}); + + +readonly class AnnotatedArrayItem +{ + public function __construct(public int $value) {} +} + +readonly class AnnotatedArrayObject +{ + /** + * @param ?array $items + */ + public function __construct(public ?array $items) {} +} + +test('annotated array with object', function () { + expect(RuleFactory::instance()->make(AnnotatedArrayObject::class))->toBe([ + 'items' => ['nullable', 'array'], + 'items.*' => ['required'], + 'items.*.value' => ['required', 'numeric'], + ]); +}); + +readonly class FlattenedLeaf +{ + public function __construct(public ?bool $flag) {} +} + +readonly class NotFlattenedLeaf +{ + public function __construct(public string $description) {} +} + +readonly class FlattenedNode +{ + public function __construct( + public string $id, + public NotFlattenedLeaf $leaf, + #[Flat] + public FlattenedLeaf $squish, + public int $level = 1, + ) {} +} + +readonly class FlattenedRoot +{ + public function __construct( + public int $value, + #[Flat] + public FlattenedNode $node, + ) {} +} + +test('flattened basic', function () { + expect(RuleFactory::instance()->make(FlattenedRoot::class))->toBe([ + 'value' => ['required', 'numeric'], + 'id' => ['required' ], + 'leaf' => ['required'], + 'leaf.description' => ['required'], + 'flag' => ['nullable', 'boolean'], + 'level' => ['sometimes', 'numeric'], + ]); +}); + +readonly class AnnotatedCollectionItem +{ + public function __construct(public int $value) {} +} + +readonly class AnnotatedCollection +{ + /** + * @param Collection $group + */ + public function __construct(public Collection $group) {} +} + +test('annotated collection', function () { + expect(RuleFactory::instance()->make(AnnotatedCollection::class))->toBe([ + 'group' => ['required', 'array'], + 'group.*' => ['required'], + 'group.*.value' => ['required', 'numeric'], + ]); +}); + + +readonly class MergedRules +{ + public function __construct(public int $value, public string $text) {} + + /** + * @return array> + */ + public static function rules(): array + { + // only customized fields need to be provided + return [ + 'value' => ['min:20'], + ]; + } +} + +test('merging rules', function () { + expect(RuleFactory::instance()->make(MergedRules::class))->toBe([ + 'value' => ['required', 'numeric', 'min:20'], + 'text' => ['required'], + ]); +}); + +readonly class OverwriteRules +{ + // union types are not supported, generated rules are undefined + public function __construct(public int|bool $value, public string $text) {} + + /** + * @return array> + */ + #[Overwrite] + public static function rules(): array + { + // when overwriting, all fields must be provided with all rules, disables rules inference. + return [ + 'value' => ['required', function ($attribute, $value, $fail) { + if (!is_int($value) && !is_bool($value)) { + $fail("$attribute must be an integer or an array."); + } + }], + 'text' => ['required'], + ]; + } +} + +test('overwriting rules', function () { + expect(RuleFactory::instance()->make(OverwriteRules::class))->toHaveKeys(['value', 'text']); }); diff --git a/tests/Rules/WithEmptyOverwriteRules.php b/tests/Rules/WithEmptyOverwriteRules.php deleted file mode 100644 index e743bed..0000000 --- a/tests/Rules/WithEmptyOverwriteRules.php +++ /dev/null @@ -1,23 +0,0 @@ - ['max:20'], - ]; - } -} diff --git a/tests/Rules/WithOverwriteRules.php b/tests/Rules/WithOverwriteRules.php deleted file mode 100644 index 21b2145..0000000 --- a/tests/Rules/WithOverwriteRules.php +++ /dev/null @@ -1,25 +0,0 @@ - ['numeric', 'max:20'], - ]; - } -} diff --git a/tests/RulesTest.php b/tests/RulesTest.php deleted file mode 100644 index 8ffeff5..0000000 --- a/tests/RulesTest.php +++ /dev/null @@ -1,161 +0,0 @@ -make(BasicPrimitives::class))->toBe([ - 'text' => ['required'], - 'number' => ['required', 'numeric'], - 'flag' => ['required', 'boolean'], - 'items' => ['nullable', 'array'], - 'floating' => ['sometimes', 'numeric'], - ]); -}); - -readonly class AnnotatedArray -{ - /** - * @param array $items - */ - public function __construct(public array $items) {} -} -test('annotated array', function () { - expect(RuleFactory::instance()->make(AnnotatedArray::class))->toBe([ - 'items' => ['required', 'array'], - 'items.*' => ['required', 'numeric'], - ]); -}); - -readonly class AnnotatedArrayNullableValue -{ - /** - * @param array $items - */ - public function __construct(public array $items) {} -} -test('annotated array with nullable items', function () { - expect(RuleFactory::instance()->make(AnnotatedArrayNullableValue::class))->toBe([ - 'items' => ['required', 'array'], - 'items.*' => ['nullable', 'numeric'], - ]); -}); - -readonly class PlainLeaf -{ - public function __construct(public string $name) {} -} -readonly class PlainRoot -{ - public function __construct(public int $value, public PlainLeaf $leaf) {} -} - -test('plain nesting', function () { - expect(RuleFactory::instance()->make(PlainRoot::class))->toBe([ - 'value' => ['required', 'numeric'], - 'leaf' => ['required'], - 'leaf.name' => ['required'], - ]); -}); - - -readonly class AnnotatedArrayItem -{ - public function __construct(public int $value) {} -} - -readonly class AnnotatedArrayObject -{ - /** - * @param ?array $items - */ - public function __construct(public ?array $items) {} -} - -test('annotated array with object', function () { - expect(RuleFactory::instance()->make(AnnotatedArrayObject::class))->toBe([ - 'items' => ['nullable', 'array'], - 'items.*' => ['required'], - 'items.*.value' => ['required', 'numeric'], - ]); -}); - -readonly class FlattenedLeaf -{ - public function __construct(public ?bool $flag) {} -} - -readonly class NotFlattenedLeaf -{ - public function __construct(public string $description) {} -} - -readonly class FlattenedNode -{ - public function __construct( - public string $id, - public NotFlattenedLeaf $leaf, - #[Flat] - public FlattenedLeaf $squish, - public int $level = 1, - ) {} -} - -readonly class FlattenedRoot -{ - public function __construct( - public int $value, - #[Flat] - public FlattenedNode $node, - ) {} -} - -test('flattened basic', function () { - expect(RuleFactory::instance()->make(FlattenedRoot::class))->toBe([ - 'value' => ['required', 'numeric'], - 'id' => ['required' ], - 'leaf' => ['required'], - 'leaf.description' => ['required'], - 'flag' => ['nullable', 'boolean'], - 'level' => ['sometimes', 'numeric'], - ]); -}); - -readonly class AnnotatedCollectionItem -{ - public function __construct(public int $value) {} -} - -readonly class AnnotatedCollection -{ - /** - * @param Collection $group - */ - public function __construct(public Collection $group) {} -} - -test('annotated collection', function () { - expect(RuleFactory::instance()->make(AnnotatedCollection::class))->toBe([ - 'group' => ['required', 'array'], - 'group.*' => ['required'], - 'group.*.value' => ['required', 'numeric'], - ]); -}); diff --git a/tests/Values/ValuesTest.php b/tests/Values/ValuesTest.php new file mode 100644 index 0000000..e592d17 --- /dev/null +++ b/tests/Values/ValuesTest.php @@ -0,0 +1,273 @@ + 'abc', + 'number' => 42, + 'flag' => true, + 'items' => ['a', 2, false], + 'floating' => 32.6, + ]); + + expect($object->text)->toBe('abc'); + expect($object->number)->toBe(42); + expect($object->flag)->toBe(true); + expect($object->items)->toBe(['a', 2, false]); + expect($object->floating)->toBe(32.6); +}); + +test('uses default values', function () { + $object = ValueFactory::make(BasicPrimitives::class, [ + 'text' => 'abc', + 'number' => 42, + 'flag' => true, + 'items' => ['a', 2, false], + ]); + + expect($object->text)->toBe('abc'); + expect($object->number)->toBe(42); + expect($object->flag)->toBe(true); + expect($object->items)->toBe(['a', 2, false]); + expect($object->floating)->toBe(4.7); +}); + +test('uses default when null and not nullable', function () { + $object = ValueFactory::make(BasicPrimitives::class, [ + 'text' => 'abc', + 'number' => 42, + 'flag' => true, + 'items' => ['a', 2, false], + 'floating' => null, + ]); + + expect($object->text)->toBe('abc'); + expect($object->number)->toBe(42); + expect($object->flag)->toBe(true); + expect($object->items)->toBe(['a', 2, false]); + expect($object->floating)->toBe(4.7); +}); + +test('accepts null when nullable', function () { + $object = ValueFactory::make(BasicPrimitives::class, [ + 'text' => 'abc', + 'number' => 42, + 'flag' => true, + 'items' => null, + ]); + + expect($object->text)->toBe('abc'); + expect($object->number)->toBe(42); + expect($object->flag)->toBe(true); + expect($object->items)->toBe(null); + expect($object->floating)->toBe(4.7); +}); + +test('accepts missing as null when nullable', function () { + $object = ValueFactory::make(BasicPrimitives::class, [ + 'text' => 'abc', + 'number' => 42, + 'flag' => true, + ]); + + expect($object->text)->toBe('abc'); + expect($object->number)->toBe(42); + expect($object->flag)->toBe(true); + expect($object->items)->toBe(null); + expect($object->floating)->toBe(4.7); +}); + +readonly class NestedLeaf +{ + public function __construct(public bool $flag) {} +} + +readonly class RootWithNestedLeaf +{ + public function __construct(public int $value, public NestedLeaf $leaf) {} +} + +test('creates nested object', function () { + $root = ValueFactory::make(RootWithNestedLeaf::class, [ + 'value' => 42, + 'leaf' => [ + 'flag' => true, + ], + ]); + + expect($root->value)->toBe(42); + expect($root->leaf->flag)->toBe(true); +}); + + +readonly class CollectionItem +{ + public function __construct(public int $value) {} +} + +readonly class CollectionRoot +{ + /** + * @param Collection $items + */ + public function __construct(public string $text, public Collection $items) {} +} + +test('creates collection object', function () { + $root = ValueFactory::make(CollectionRoot::class, [ + 'text' => 'abc', + 'items' => [ + [ 'value' => 1 ], + [ 'value' => 2 ], + [ 'value' => 4 ], + [ 'value' => 8 ], + ], + ]); + + expect($root->text)->toBe('abc'); + expect($root->items)->toBeInstanceOf(Collection::class); + expect($root->items->count())->toBe(4); + expect($root->items[0]->value)->toBe(1); + expect($root->items[1]->value)->toBe(2); + expect($root->items[2]->value)->toBe(4); + expect($root->items[3]->value)->toBe(8); +}); + +readonly class DoubleCast +{ + public static function cast(int $data): int + { + return $data * 2; + } +} + +readonly class WithExplicitCast +{ + public function __construct( + #[CastWith(DoubleCast::class)] + public int $value, + ) {} +} + +readonly class WithNestedCast +{ + /** + * @param array $items + */ + public function __construct(public array $items) {} +} + +test('with explicit cast', function () { + $object = ValueFactory::make(WithExplicitCast::class, ['value' => 32]); + expect($object->value)->toBe(64); +}); + +test('with nested cast', function () { + $object = ValueFactory::make(WithNestedCast::class, ['items' => [ ['value' => 2], ['value' => 3], ['value' => 5]]]); + + expect($object->items[0]->value)->toBe(4); + expect($object->items[1]->value)->toBe(6); + expect($object->items[2]->value)->toBe(10); +}); + +readonly class CarbonPeriodCast +{ + /** + * @param array $data + */ + public static function cast(array $data): CarbonPeriod + { + return new CarbonPeriod(Carbon::parse($data['start']), Carbon::parse($data['end'])); + } +} + +readonly class WithObjectCast +{ + public function __construct( + #[CastWith(CarbonPeriodCast::class)] + public CarbonPeriod $period, + ) {} +} + +test('with object cast', function () { + $object = ValueFactory::make(WithObjectCast::class, ['period' => ['start' => '1980-10-01', 'end' => '1990-06-01']]); + + expect($object->period)->toBeInstanceOf(CarbonPeriod::class); + expect($object->period->start->format('Y-m-d'))->toBe('1980-10-01'); + expect($object->period->end->format('Y-m-d'))->toBe('1990-06-01'); +}); + +enum Status: string +{ + case ACTIVE = 'active'; + case INACTIVE = 'inactive'; + case PENDING = 'pending'; +} + +readonly class TaskDTOWithEnum +{ + public function __construct( + public string $title, + public Status $status, + ) {} +} + +readonly class TaskDTOWithNullableEnum +{ + public function __construct( + public string $title, + public ?Status $status, + ) {} +} + +test('backed enum properly cast from validated data', function () { + $object = ValueFactory::make(TaskDTOWithEnum::class, [ + 'title' => 'Task 1', + 'status' => 'active', + ]); + + expect($object->title)->toBe('Task 1') + ->and($object->status)->toBeInstanceOf(Status::class) + ->and($object->status)->toBe(Status::ACTIVE); +}); + +test('nullable backed enum with null', function () { + $object = ValueFactory::make(TaskDTOWithNullableEnum::class, [ + 'title' => 'Task 1', + 'status' => null, + ]); + + expect($object->title)->toBe('Task 1') + ->and($object->status)->toBeNull(); +}); + +test('nullable backed enum with valid value', function () { + $object = ValueFactory::make(TaskDTOWithNullableEnum::class, [ + 'title' => 'Task 1', + 'status' => 'pending', + ]); + + expect($object->title)->toBe('Task 1') + ->and($object->status)->toBeInstanceOf(Status::class) + ->and($object->status)->toBe(Status::PENDING); +}); diff --git a/workbench/config/dto.php b/workbench/config/dto.php index c89046d..ecf8413 100644 --- a/workbench/config/dto.php +++ b/workbench/config/dto.php @@ -1,8 +1,13 @@ [ + Collection::class => CustomHandlers::class . "::CollectionRules", + ], 'logging' => [ 'channel' => 'dto', 'context' => [ @@ -12,4 +17,7 @@ return [ 'internals' => LogLevel::DEBUG, ], ], + 'listTypes' => [ + Collection::class, + ], ]; diff --git a/workbench/phpunit.xml b/workbench/phpunit.xml deleted file mode 100644 index 0788ab5..0000000 --- a/workbench/phpunit.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - tests - - - - - app - - -