diff --git a/composer.json b/composer.json index 5f263ef..b415c1c 100644 --- a/composer.json +++ b/composer.json @@ -1,7 +1,6 @@ { "name": "icefox/dto", "type": "library", - "version": "0.0.1", "require": { "laravel/framework": "^11.0", "psr/log": "^3.0", @@ -19,12 +18,15 @@ "license": "GPL-2.0-only", "autoload": { "psr-4": { - "Icefox\\Data\\": "src/" + "Icefox\\DTO\\": "src/" } }, "autoload-dev": { "psr-4": { - "Tests\\": "tests/" + "Tests\\": "tests/", + "Workbench\\App\\": "workbench/app/", + "Workbench\\Database\\Factories\\": "workbench/database/factories/", + "Workbench\\Database\\Seeders\\": "workbench/database/seeders/" } }, "authors": [ diff --git a/src/Attributes/CastWith.php b/src/Attributes/CastWith.php index 25c32cf..6d544c5 100644 --- a/src/Attributes/CastWith.php +++ b/src/Attributes/CastWith.php @@ -2,7 +2,8 @@ declare(strict_types=1); -namespace Icefox\Data\Attributes; +namespace Icefox\DTO\Attributes; + use Attribute; #[Attribute(Attribute::TARGET_PARAMETER)] diff --git a/src/Attributes/Flat.php b/src/Attributes/Flat.php index 2664003..27d73ad 100644 --- a/src/Attributes/Flat.php +++ b/src/Attributes/Flat.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Icefox\Data\Attributes; +namespace Icefox\DTO\Attributes; use Attribute; diff --git a/src/Attributes/FromInput.php b/src/Attributes/FromInput.php index d43b697..0a536bc 100644 --- a/src/Attributes/FromInput.php +++ b/src/Attributes/FromInput.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Icefox\Data\Attributes; +namespace Icefox\DTO\Attributes; use Attribute; diff --git a/src/Attributes/FromRouteParameter.php b/src/Attributes/FromRouteParameter.php index d104e1c..4eb11d1 100644 --- a/src/Attributes/FromRouteParameter.php +++ b/src/Attributes/FromRouteParameter.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Icefox\Data\Attributes; +namespace Icefox\DTO\Attributes; use Attribute; diff --git a/src/Attributes/Overwrite.php b/src/Attributes/OverwriteRules.php similarity index 60% rename from src/Attributes/Overwrite.php rename to src/Attributes/OverwriteRules.php index a797cc6..e1cee40 100644 --- a/src/Attributes/Overwrite.php +++ b/src/Attributes/OverwriteRules.php @@ -2,9 +2,11 @@ declare(strict_types=1); -namespace Icefox\Data\Attributes; +namespace Icefox\DTO\Attributes; use Attribute; #[Attribute(Attribute::TARGET_METHOD)] -class Overwrite {} +class OverwriteRules +{ +} diff --git a/src/Config.php b/src/Config.php new file mode 100644 index 0000000..f827157 --- /dev/null +++ b/src/Config.php @@ -0,0 +1,63 @@ + 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/DataObject.php b/src/DataObject.php index 69b15e5..95a7235 100644 --- a/src/DataObject.php +++ b/src/DataObject.php @@ -2,14 +2,13 @@ declare(strict_types=1); -namespace Icefox\Data; +namespace Icefox\DTO; -use Icefox\Data\Factories\DataObjectFactory; use Illuminate\Http\Request; trait DataObject { - public static function fromRequest(Request $request): ?static + public static function fromRequest(Request $request): mixed { return DataObjectFactory::fromRequest(static::class, $request); } diff --git a/src/DataObjectFactory.php b/src/DataObjectFactory.php new file mode 100644 index 0000000..adbf200 --- /dev/null +++ b/src/DataObjectFactory.php @@ -0,0 +1,81 @@ +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/CustomHandlers.php b/src/Factories/CollectionFactory.php similarity index 75% rename from src/CustomHandlers.php rename to src/Factories/CollectionFactory.php index cf0eb15..bfd0fe0 100644 --- a/src/CustomHandlers.php +++ b/src/Factories/CollectionFactory.php @@ -1,16 +1,17 @@ */ - public static function CollectionRules(ParameterMeta $parameter, RuleFactory $factory): array + public static function rules(ParameterMeta $parameter, RuleFactory $factory): array { if (is_null($parameter->tag)) { return []; diff --git a/src/Factories/DataObjectFactory.php b/src/Factories/DataObjectFactory.php deleted file mode 100644 index 96b3c9c..0000000 --- a/src/Factories/DataObjectFactory.php +++ /dev/null @@ -1,144 +0,0 @@ -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/IData.php b/src/IData.php deleted file mode 100644 index 0f2e749..0000000 --- a/src/IData.php +++ /dev/null @@ -1,5 +0,0 @@ -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 c63d95f..9d814d1 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 fa64c03..5c92a22 100644 --- a/src/ReflectionHelper.php +++ b/src/ReflectionHelper.php @@ -1,13 +1,10 @@ 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/src/Factories/RuleFactory.php b/src/Support/RuleFactory.php similarity index 90% rename from src/Factories/RuleFactory.php rename to src/Support/RuleFactory.php index df1bdd7..9aa1da5 100644 --- a/src/Factories/RuleFactory.php +++ b/src/Support/RuleFactory.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace Icefox\Data\Factories; +namespace Icefox\DTO\Support; -use Icefox\Data\Attributes\Flat; -use Icefox\Data\Attributes\Overwrite; -use Icefox\Data\ParameterMeta; -use Icefox\Data\ReflectionHelper; -use Icefox\Data\Factories\RuleFactory; +use Icefox\DTO\Attributes\Flat; +use Icefox\DTO\Attributes\OverwriteRules; +use Icefox\DTO\Config; +use Icefox\DTO\ParameterMeta; +use Icefox\DTO\ReflectionHelper; 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('dto.rules.' . $name, null)) { + if ($globalRules = Config::getRules($name)) { 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(Overwrite::class))) { + if ($hasRulesMethod && !empty($classReflection->getMethod('rules')->getAttributes(OverwriteRules::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/Factories/ValueFactory.php b/src/Support/ValueFactory.php similarity index 75% rename from src/Factories/ValueFactory.php rename to src/Support/ValueFactory.php index 835d7ea..2781ae2 100644 --- a/src/Factories/ValueFactory.php +++ b/src/Support/ValueFactory.php @@ -2,13 +2,17 @@ declare(strict_types=1); -namespace Icefox\Data\Factories; +namespace Icefox\DTO\Support; -use Icefox\Data\Attributes\CastWith; -use Icefox\Data\Attributes\Flat; -use Icefox\Data\ReflectionHelper; +use Icefox\DTO\Attributes\CastWith; +use Icefox\DTO\Attributes\Flat; +use Icefox\DTO\Config; +use Icefox\DTO\ParameterMeta; +use Icefox\DTO\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; @@ -20,6 +24,21 @@ 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) { @@ -72,24 +91,17 @@ class ValueFactory public static function resolveDeclaredTypeValue(ReflectionNamedType $parameter, mixed $rawValue): mixed { - $type = $parameter->getName(); - if (is_a($type, \BackedEnum::class, true)) { - return $type::from($rawValue); - } - - return match ($type) { + return match ($parameter->getName()) { 'string' => $rawValue, 'bool' => boolval($rawValue), 'int' => intval($rawValue), 'float' => floatval($rawValue), 'array' => $rawValue, - default => self::make($type, $rawValue), + default => self::make($parameter->getName(), $rawValue), + }; } - /** - * @param array $input - */ public static function make(string $class, array $input): object { $parameters = ReflectionHelper::getParametersMeta($class); @@ -100,9 +112,9 @@ class ValueFactory $parameterArgs = empty($parameter->reflection->getAttributes(Flat::class)) ? ($input[$name] ?? null) : $input; if (is_null($parameterArgs)) { - $arguments[$name] = $parameter->reflection->isDefaultValueAvailable() - ? $parameter->reflection->getDefaultValue() - : null; + if ($parameter->reflection->allowsNull()) { + $arguments[$name] = null; + } continue; } @@ -116,27 +128,25 @@ class ValueFactory $type = $parameter->tag?->getType(); if (empty($parameterClass) && $type instanceof Object_) { - $parameterClass = $type->getFqsen()?->__toString(); + $parameterClass = $type->getFqsen(); } - if (!empty($parameterClass) && $caster = config('dto.cast.' . $parameterClass, null)) { $arguments[$name] = App::call($caster, ['data' => $parameterArgs]); continue; } - if (!is_null($type)) { + if ($parameter->tag instanceof Param) { $arguments[$name] = self::resolveAnnotatedValue($type, $parameterArgs); continue; } - $reflectionType = $parameter->reflection->getType(); - if ($reflectionType instanceof ReflectionNamedType) { - $arguments[$name] = self::resolveDeclaredTypeValue($reflectionType, $parameterArgs); + if ($parameter->reflection->getType() instanceof ReflectionNamedType) { + $arguments[$name] = self::resolveDeclaredTypeValue($parameter->reflection->getType(), $parameterArgs); continue; } $arguments[$name] = $parameterArgs; } - return new $class(...$arguments); + return App::makeWith($class, $arguments); } } diff --git a/tests/Casters/CasterTest.php b/tests/Casters/CasterTest.php new file mode 100644 index 0000000..93fc5ce --- /dev/null +++ b/tests/Casters/CasterTest.php @@ -0,0 +1,71 @@ + []]); + }); + + 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 new file mode 100644 index 0000000..1f08683 --- /dev/null +++ b/tests/Casters/SimpleValue.php @@ -0,0 +1,10 @@ + ['required', 'numeric'], + ]; + } +} diff --git a/tests/Casters/WithGlobalCaster.php b/tests/Casters/WithGlobalCaster.php new file mode 100644 index 0000000..d829310 --- /dev/null +++ b/tests/Casters/WithGlobalCaster.php @@ -0,0 +1,16 @@ + $values + */ + public function __construct(public array $values) {} +} diff --git a/tests/Classes/CarbonPeriodMapper.php b/tests/Classes/CarbonPeriodMapper.php new file mode 100644 index 0000000..4edee01 --- /dev/null +++ b/tests/Classes/CarbonPeriodMapper.php @@ -0,0 +1,22 @@ + ['required', 'date'], + 'end' => ['required', 'date'], + ]; + } +} diff --git a/tests/Classes/CollectionDataObject.php b/tests/Classes/CollectionDataObject.php new file mode 100644 index 0000000..711dbc7 --- /dev/null +++ b/tests/Classes/CollectionDataObject.php @@ -0,0 +1,17 @@ + $values + */ + public function __construct(public Collection $values) {} +} diff --git a/tests/Classes/FailsReturnsDefault.php b/tests/Classes/FailsReturnsDefault.php new file mode 100644 index 0000000..7fdba3a --- /dev/null +++ b/tests/Classes/FailsReturnsDefault.php @@ -0,0 +1,23 @@ +json(['errors' => $validator->errors()], 422) + ); + } +} diff --git a/tests/Classes/FromInputObject.php b/tests/Classes/FromInputObject.php new file mode 100644 index 0000000..575e44c --- /dev/null +++ b/tests/Classes/FromInputObject.php @@ -0,0 +1,19 @@ + ['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 new file mode 100644 index 0000000..b68a4b7 --- /dev/null +++ b/tests/DataObjectTest.php @@ -0,0 +1,212 @@ +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 new file mode 100644 index 0000000..11b2ca1 --- /dev/null +++ b/tests/FailedValidation/FailsMethodTest.php @@ -0,0 +1,108 @@ + 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 deleted file mode 100644 index d0713e3..0000000 --- a/tests/Http/RequestTest.php +++ /dev/null @@ -1,107 +0,0 @@ -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 new file mode 100644 index 0000000..c4bff3d --- /dev/null +++ b/tests/Logging/CustomLogger.php @@ -0,0 +1,36 @@ +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 new file mode 100644 index 0000000..6847938 --- /dev/null +++ b/tests/Logging/LogTest.php @@ -0,0 +1,281 @@ +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 f902d73..5f7ed9f 100644 --- a/tests/Rules/RulesTest.php +++ b/tests/Rules/RulesTest.php @@ -4,208 +4,97 @@ declare(strict_types=1); namespace Tests\Rules; -use Icefox\Data\Attributes\Flat; -use Icefox\Data\Attributes\Overwrite; -use Icefox\Data\Factories\RuleFactory; -use Illuminate\Support\Collection; +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; -readonly class BasicPrimitives -{ - public function __construct( - public string $text, - public int $number, - public bool $flag, - public ?array $items, - public float $floating = 0.0, - ) {} -} +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, ''); -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'], - ]); + 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'], + ]); + }); }); -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('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 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 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 PlainLeaf -{ - public function __construct(public string $name) {} -} -readonly class PlainRoot -{ - public function __construct(public int $value, public PlainLeaf $leaf) {} -} +describe('rules overwrite', function () { + it('uses only custom rules when OverwriteRules attribute is present', function () { + $object = WithOverwriteRules::fromArray([ + 'value' => 10, + ]); -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']); + 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']); + }); }); diff --git a/tests/Rules/WithEmptyOverwriteRules.php b/tests/Rules/WithEmptyOverwriteRules.php new file mode 100644 index 0000000..e743bed --- /dev/null +++ b/tests/Rules/WithEmptyOverwriteRules.php @@ -0,0 +1,23 @@ + ['max:20'], + ]; + } +} diff --git a/tests/Rules/WithOverwriteRules.php b/tests/Rules/WithOverwriteRules.php new file mode 100644 index 0000000..21b2145 --- /dev/null +++ b/tests/Rules/WithOverwriteRules.php @@ -0,0 +1,25 @@ + ['numeric', 'max:20'], + ]; + } +} diff --git a/tests/RulesTest.php b/tests/RulesTest.php new file mode 100644 index 0000000..8ffeff5 --- /dev/null +++ b/tests/RulesTest.php @@ -0,0 +1,161 @@ +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 deleted file mode 100644 index e592d17..0000000 --- a/tests/Values/ValuesTest.php +++ /dev/null @@ -1,273 +0,0 @@ - '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 ecf8413..c89046d 100644 --- a/workbench/config/dto.php +++ b/workbench/config/dto.php @@ -1,13 +1,8 @@ [ - Collection::class => CustomHandlers::class . "::CollectionRules", - ], 'logging' => [ 'channel' => 'dto', 'context' => [ @@ -17,7 +12,4 @@ return [ 'internals' => LogLevel::DEBUG, ], ], - 'listTypes' => [ - Collection::class, - ], ]; diff --git a/workbench/phpunit.xml b/workbench/phpunit.xml new file mode 100644 index 0000000..0788ab5 --- /dev/null +++ b/workbench/phpunit.xml @@ -0,0 +1,17 @@ + + + + + tests + + + + + app + + +