From 367858c97cb9b911027597a81a4d2401a08fb4fb Mon Sep 17 00:00:00 2001 From: icefox Date: Mon, 23 Feb 2026 21:09:02 -0300 Subject: [PATCH] workbench, tests --- composer.json | 29 +++- composer.lock | 2 +- flake.nix | 12 +- src/Config.php | 53 ++++++- src/DataObject.php | 98 +------------ src/DataObjectFactory.php | 81 +++++++++++ src/DataObjectServiceProvider.php | 27 ---- src/Factories/CollectionFactory.php | 18 ++- src/InputFactory.php | 41 ++++++ src/Log.php | 67 --------- src/Support/RuleFactory.php | 95 +++++++------ src/Support/ValueFactory.php | 114 +++++++++------ src/config/dto.php | 21 --- tests/Casters/CasterTest.php | 12 +- tests/Casters/SimpleValueCaster.php | 4 +- tests/Casters/WithGlobalCaster.php | 2 +- tests/Classes/CarbonPeriodMapper.php | 4 +- tests/Flattening/Classes/BasicRoot.php | 12 -- tests/Flattening/Classes/RequiredLeaf.php | 12 -- tests/Flattening/FlatteningTest.php | 18 --- tests/Rules/RulesTest.php | 26 +--- tests/RulesTest.php | 161 ++++++++++++++++++++++ tests/TestCase.php | 14 +- workbench/bootstrap/app.php | 7 - workbench/config/dto.php | 15 ++ workbench/config/logging.php | 16 +++ workbench/phpunit.xml | 17 +++ 27 files changed, 568 insertions(+), 410 deletions(-) create mode 100644 src/DataObjectFactory.php delete mode 100644 src/DataObjectServiceProvider.php create mode 100644 src/InputFactory.php delete mode 100644 src/Log.php delete mode 100644 src/config/dto.php delete mode 100644 tests/Flattening/Classes/BasicRoot.php delete mode 100644 tests/Flattening/Classes/RequiredLeaf.php delete mode 100644 tests/Flattening/FlatteningTest.php create mode 100644 tests/RulesTest.php delete mode 100644 workbench/bootstrap/app.php create mode 100644 workbench/config/dto.php create mode 100644 workbench/config/logging.php create mode 100644 workbench/phpunit.xml diff --git a/composer.json b/composer.json index be192b0..b415c1c 100644 --- a/composer.json +++ b/composer.json @@ -12,7 +12,8 @@ "phpstan/phpstan": "^2.1", "friendsofphp/php-cs-fixer": "^3.94", "orchestra/testbench": "^9.16", - "pestphp/pest-plugin-laravel": "^4.0" + "pestphp/pest-plugin-laravel": "^4.0", + "laravel/pail": "^1.2" }, "license": "GPL-2.0-only", "autoload": { @@ -22,7 +23,10 @@ }, "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": [ @@ -35,5 +39,26 @@ "allow-plugins": { "pestphp/pest-plugin": true } + }, + "scripts": { + "post-autoload-dump": [ + "@clear", + "@prepare" + ], + "clear": "@php vendor/bin/testbench package:purge-skeleton --ansi", + "prepare": "@php vendor/bin/testbench package:discover --ansi", + "build": "@php vendor/bin/testbench workbench:build --ansi", + "serve": [ + "Composer\\Config::disableProcessTimeout", + "@build", + "@php vendor/bin/testbench serve --ansi" + ], + "lint": [ + "@php vendor/bin/phpstan analyse --verbose --ansi" + ], + "test": [ + "@clear", + "@php vendor/bin/pest" + ] } } diff --git a/composer.lock b/composer.lock index 27eafed..d018f04 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "336ed1e898bc39b0e9990becc327415c", + "content-hash": "0ecb4cd71aa6ab475ed1db205f47b632", "packages": [ { "name": "brick/math", diff --git a/flake.nix b/flake.nix index ee80124..49574dd 100644 --- a/flake.nix +++ b/flake.nix @@ -16,10 +16,20 @@ system: let pkgs = nixpkgs.legacyPackages.${system}; + php = ( + pkgs.php.withExtensions ( + { enabled, all }: + enabled + ++ [ + all.pcntl + all.xdebug + ] + ) + ); in { devShells.default = pkgs.mkShell { - packages = with pkgs; [ + packages = [ php php.packages.composer ]; diff --git a/src/Config.php b/src/Config.php index a25d8df..f827157 100644 --- a/src/Config.php +++ b/src/Config.php @@ -4,15 +4,60 @@ declare(strict_types=1); namespace Icefox\DTO; +use Icefox\DTO\Support\RuleFactory; +use Illuminate\Support\Collection; +use phpDocumentor\Reflection\PseudoTypes\Generic; + class Config { - public static function getCaster(string $className): ?callable + /** + * @param class-string $class + **/ + public static function getCaster(string $class): ?callable { - return config('dto.cast.' . $className, null); + return config('dto.cast.' . $class, null); } - public static function getRules(string $className): ?callable + /** + * @param class-string $class + **/ + public static function getRules(string $class): ?callable { - return config('dto.rules.' . $className, null); + if ($userDefined = config('dto.rules.' . $class, null)) { + return $userDefined; + } + return match ($class) { + Collection::class => 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 1c1fc1e..95a7235 100644 --- a/src/DataObject.php +++ b/src/DataObject.php @@ -4,40 +4,13 @@ declare(strict_types=1); namespace Icefox\DTO; -use Icefox\DTO\Attributes\FromInput; -use Icefox\DTO\Attributes\CastWith; -use Icefox\DTO\Attributes\FromRouteParameter; -use Icefox\DTO\Attributes\OverwriteRules; -use Icefox\DTO\Support\RuleFactory; -use Icefox\DTO\Support\ValueFactory; use Illuminate\Http\Request; -use Illuminate\Support\Facades\App; -use Illuminate\Validation\Rule; -use Illuminate\Validation\ValidationException; -use Illuminate\Validation\Validator; -use ReflectionClass; -use phpDocumentor\Reflection\DocBlock\Tags\Param; trait DataObject { public static function fromRequest(Request $request): mixed { - $reflection = new ReflectionClass(static::class); - $constructor = $reflection->getConstructor(); - - $input = []; - foreach ($constructor->getParameters() as $parameter) { - $parameterName = $parameter->getName(); - - foreach ($parameter->getAttributes(FromRouteParameter::class) as $attr) { - $name = $attr->newInstance()->name; - if ($value = $request->input($name, null)) { - $input[$parameterName] = $value; - continue 2; - } - } - } - return static::fromArray($input); + return DataObjectFactory::fromRequest(static::class, $request); } /** @@ -45,73 +18,6 @@ trait DataObject */ public static function fromArray(array $input): ?static { - $logger = new Log(); - $parameters = ReflectionHelper::getParametersMeta(static::class); - foreach ($parameters as $parameter) { - $parameterName = $parameter->reflection->getName(); - - 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(static::class); - - $validator = static::withValidator($input, $rules); - - if ($validator->fails()) { - $logger->validationErrors($validator->errors()->toArray()); - return static::fails($validator); - } - - $mappedInput = []; - foreach ($parameters as $parameter) { - $parameterName = $parameter->reflection->getName(); - - if ($castWith = array_first($parameter->reflection->getAttributes(CastWith::class))) { - $value = App::call( - [App::make($castWith->newInstance()->class), 'cast'], - ['value' => $validator->getValue($parameterName)], - ); - $mappedInput[$parameterName] = $value; - continue; - } - - $mappedInput[$parameterName] = ValueFactory::resolveValue( - $validator->getValue($parameterName), - $parameter->tag instanceof Param ? $parameter->tag->getType() : null, - $parameter->reflection, - ); - } - $logger->input($mappedInput); - return App::make(static::class, $mappedInput); - } - - public static function fails(Validator $validator): ?static - { - throw new ValidationException($validator); - } - - /** - * @param array $data - * @param array> $rules - */ - public static function withValidator(array $data, array $rules): Validator - { - return App::makeWith(Validator::class, ['data' => $data, 'rules' => $rules]); + return DataObjectFactory::fromArray(static::class, $input); } } 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/DataObjectServiceProvider.php b/src/DataObjectServiceProvider.php deleted file mode 100644 index b92cd00..0000000 --- a/src/DataObjectServiceProvider.php +++ /dev/null @@ -1,27 +0,0 @@ -mergeConfigFrom(__DIR__ . '/config/dto.php', 'dto'); - } - - /** - * Bootstrap services. - */ - public function boot(): void - { - // - } -} diff --git a/src/Factories/CollectionFactory.php b/src/Factories/CollectionFactory.php index 5696dad..bfd0fe0 100644 --- a/src/Factories/CollectionFactory.php +++ b/src/Factories/CollectionFactory.php @@ -2,24 +2,22 @@ namespace Icefox\DTO\Factories; +use Icefox\DTO\ParameterMeta; use Icefox\DTO\Support\RuleFactory; -use Illuminate\Support\Collection; -use Illuminate\Validation\Rule; -use ReflectionParameter; use phpDocumentor\Reflection\PseudoTypes\Generic; -use phpDocumentor\Reflection\Type; class CollectionFactory { /** - * @return array + * @return array */ - public static function rules(ReflectionParameter $parameter, ?Type $type): array + public static function rules(ParameterMeta $parameter, RuleFactory $factory): array { - if (is_null($type)) { + if (is_null($parameter->tag)) { return []; } + $type = $parameter->tag->getType(); if (!$type instanceof Generic) { return []; } @@ -27,14 +25,14 @@ class CollectionFactory $subtypes = $type->getTypes(); if (count($subtypes) == 0) { - return []; + return ['' => ['array']]; } $subtype = count($subtypes) == 1 ? $subtypes[0] : $subtypes[1]; - return array_merge( + return $factory->mergeRules( ['' => ['array']], - RuleFactory::getRulesFromDocBlock($subtype, '.*'), + $factory->getRulesFromDocBlock($subtype, '.*'), ); } } diff --git a/src/InputFactory.php b/src/InputFactory.php new file mode 100644 index 0000000..7109eee --- /dev/null +++ b/src/InputFactory.php @@ -0,0 +1,41 @@ +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/Log.php b/src/Log.php deleted file mode 100644 index eb75ebf..0000000 --- a/src/Log.php +++ /dev/null @@ -1,67 +0,0 @@ -logger = App::call($raw); - return; - } - if (is_object($raw)) { - $this->logger = $raw; - return; - } - if (is_string($raw) && class_exists($raw)) { - $this->logger = App::make($raw); - return; - } - $this->logger = new NullLogger(); - } - - /** - * @param array> $rules - */ - public function rules(array $rules): void - { - $level = config('dto.log.rules') ?? LogLevel::DEBUG; - $this->logger->log($level, print_r($rules, true)); - } - - /** - * @param array $input - */ - public function input(array $input): void - { - $level = config('dto.log.input') ?? LogLevel::DEBUG; - $this->logger->log($level, print_r($input, true)); - } - - /** - * @param array $input - */ - public function inputRaw(array $input): void - { - $level = config('dto.log.raw_input') ?? LogLevel::DEBUG; - $this->logger->log($level, print_r($input, true)); - } - - /** - * @param array> $errors - */ - public function validationErrors(array $errors): void - { - $level = config('dto.log.validation_errors') ?? LogLevel::INFO; - $this->logger->log($level, print_r($errors, true)); - } -} diff --git a/src/Support/RuleFactory.php b/src/Support/RuleFactory.php index 05e4557..9aa1da5 100644 --- a/src/Support/RuleFactory.php +++ b/src/Support/RuleFactory.php @@ -4,15 +4,15 @@ declare(strict_types=1); namespace Icefox\DTO\Support; -use Icefox\DTO\Attributes\CastWith; use Icefox\DTO\Attributes\Flat; use Icefox\DTO\Attributes\OverwriteRules; use Icefox\DTO\Config; -use Icefox\DTO\Log; use Icefox\DTO\ParameterMeta; use Icefox\DTO\ReflectionHelper; use Illuminate\Support\Facades\App; +use Illuminate\Support\Facades\Log; use Illuminate\Validation\Rule; +use Psr\Log\LoggerInterface; use ReflectionClass; use ReflectionNamedType; use ReflectionUnionType; @@ -26,19 +26,19 @@ use phpDocumentor\Reflection\Types\Integer; use phpDocumentor\Reflection\Types\Nullable; use phpDocumentor\Reflection\Types\Object_; -class RuleFactory +final class RuleFactory { /** * @return array> */ - public static function getRulesFromDocBlock( + public function getRulesFromDocBlock( Type $type, string $prefix, ): array { $rules = []; if ($type instanceof Nullable) { $rules[$prefix] = ['nullable']; - $rules = array_merge($rules, self::getRulesFromDocBlock($type->getActualType(), $prefix)); + $type = $type->getActualType(); } else { $rules[$prefix] = ['required']; } @@ -47,7 +47,7 @@ class RuleFactory $rules[$prefix][] = 'array'; $valueType = $type->getValueType(); - $rules = array_merge($rules, self::getRulesFromDocBlock($valueType, $prefix . '.*')); + $rules = $this->mergeRules($rules, $this->getRulesFromDocBlock($valueType, $prefix . '.*')); } if ($type instanceof Boolean) { $rules[$prefix][] = 'boolean'; @@ -55,10 +55,7 @@ class RuleFactory $rules[$prefix][] = 'numeric'; } elseif ($type instanceof Object_) { $paramsSub = ReflectionHelper::getParametersMeta($type->getFqsen()->__toString()); - $rules = array_merge( - $rules, - self::infer($paramsSub, $prefix), - ); + $rules = $this->mergeRules($rules, $this->infer($paramsSub, $prefix)); } return $rules; } @@ -67,14 +64,14 @@ class RuleFactory * @param array $parameters * @return array> */ - public static function infer(array $parameters, string $basePrefix): array + public function infer(array $parameters, string $basePrefix): array { $rules = []; foreach ($parameters as $parameter) { $prefix = $basePrefix . (empty($basePrefix) ? '' : '.') . (empty($parameter->reflection->getAttributes(Flat::class)) ? $parameter->reflection->getName() : ''); - foreach (self::buildParameterRule($parameter, $prefix) as $key => $newRules) { + foreach ($this->buildParameterRule($parameter, $prefix) as $key => $newRules) { $rules[$key] = $newRules; } } @@ -84,7 +81,7 @@ class RuleFactory /** * @return array> */ - public static function buildParameterRule(ParameterMeta $parameter, string $prefix): array + public function buildParameterRule(ParameterMeta $parameter, string $prefix): array { $type = $parameter->reflection->getType(); @@ -93,12 +90,14 @@ class RuleFactory } $rules = [$prefix => []]; - if ($parameter->reflection->isOptional()) { - $rules[$prefix][] = 'sometimes'; - } elseif ($type->allowsNull()) { - $rules[$prefix][] = 'nullable'; - } else { - $rules[$prefix][] = 'required'; + if (!empty($prefix)) { + if ($parameter->reflection->isOptional()) { + $rules[$prefix][] = 'sometimes'; + } elseif ($type->allowsNull()) { + $rules[$prefix][] = 'nullable'; + } else { + $rules[$prefix][] = 'required'; + } } if ($type instanceof ReflectionUnionType) { @@ -108,11 +107,13 @@ class RuleFactory if ($type instanceof ReflectionNamedType && $name = $type->getName()) { if ($globalRules = Config::getRules($name)) { - foreach ($globalRules($parameter->reflection, $parameter->tag->getType()) as $scopedPrefix => $values) { + foreach ($globalRules($parameter, $this) as $scopedPrefix => $values) { $realPrefix = $prefix . $scopedPrefix; - $rules[$realPrefix] = array_values(array_unique(array_merge($rules[$realPrefix] ?? [], $values))); + $rules[$realPrefix] = array_merge($rules[$realPrefix] ?? [], $values); } + return $rules; } + if ($name === 'string') { } elseif ($name === 'bool') { $rules[$prefix][] = 'boolean'; @@ -127,38 +128,24 @@ class RuleFactory } } else { $paramsSub = ReflectionHelper::getParametersMeta($type->getName()); - $rules = array_merge( - $rules, - self::infer($paramsSub, $prefix), - ); - } - } - - foreach ($parameter->reflection->getAttributes(CastWith::class) as $attr) { - $mapperClass = $attr->newInstance()->class; - if (method_exists($mapperClass, 'rules')) { - $subRules = App::call("$mapperClass@rules"); - foreach ($subRules as $key => &$value) { - $path = empty($key) ? $prefix : ($prefix . '.' . $key); - $rules[$path] = array_values(array_unique(array_merge($rules[$path] ?? [], $value))); - } + $rules = $this->mergeRules($rules, $this->infer($paramsSub, $prefix)); } } if ($parameter->tag instanceof Param) { - $docblockRules = self::getRulesFromDocBlock( + $docblockRules = $this->getRulesFromDocBlock( $parameter->tag->getType(), $prefix, ); - foreach ($docblockRules as $key => &$values) { - $rules[$key] = array_values(array_unique(array_merge($rules[$key] ?? [], $values))); - } + $rules = $this->mergeRules($rules, $docblockRules); + } + if (empty($rules[$prefix])) { + unset($rules[$prefix]); } - return $rules; } - public function __construct(public Log $log) {} + public function __construct(public LoggerInterface $log) {} /** * @param class-string $class @@ -178,21 +165,21 @@ class RuleFactory $rules = $customRules; } else { $inferredRules = RuleFactory::infer($parameters, ''); - $rules = self::mergeRules($inferredRules, $customRules); + $rules = $this->mergeRules($inferredRules, $customRules); } - $this->log->rules($rules); + $this->log->info('Constructed rules for class ' . $class, $rules); return $rules; } /** - * @param array> $inferredRules - * @param array> $customRules + * @param array> $first + * @param array> $second * @return array> */ - protected function mergeRules(array $inferredRules, array $customRules): array + public function mergeRules(array $first, array $second): array { - $merged = $inferredRules; - foreach ($customRules as $key => $rules) { + $merged = $first; + foreach ($second as $key => $rules) { if (isset($merged[$key])) { $merged[$key] = array_values(array_unique(array_merge($merged[$key], $rules))); } else { @@ -201,4 +188,14 @@ class RuleFactory } return $merged; } + + private static self $_instance; + + public static function instance(?LoggerInterface $log = null): static + { + if (empty(self::$_instance)) { + static::$_instance = new self($log ?? Log::channel(config('dto.logging.channel'))); + } + return static::$_instance; + } } diff --git a/src/Support/ValueFactory.php b/src/Support/ValueFactory.php index 992402e..2781ae2 100644 --- a/src/Support/ValueFactory.php +++ b/src/Support/ValueFactory.php @@ -5,10 +5,14 @@ declare(strict_types=1); namespace Icefox\DTO\Support; 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; @@ -35,31 +39,37 @@ class ValueFactory return App::makeWith($className, $rawValue); } - public static function resolveTypedValue(mixed $rawValue, Type $type): mixed + public static function resolveAnnotatedValue(Type $type, mixed $rawValue): mixed { if ($type instanceof Nullable) { $type = $type->getActualType(); } - if ($type instanceof Generic) { - $types = $type->getTypes(); - $innerType = count($types) === 2 ? $types[1] : $types[0]; - $result = []; - foreach ($rawValue as $key => $value) { - $result[$key] = self::resolveTypedValue($value, $innerType); - } - return new ($type->getFqsen()->__toString())($result); - } - if ($type instanceof AbstractList) { $innerType = $type->getValueType(); $result = []; foreach ($rawValue as $key => $value) { - $result[$key] = self::resolveTypedValue($value, $innerType); + $result[$key] = self::resolveAnnotatedValue($innerType, $value); } return $result; } + if ($type instanceof Generic) { + $types = $type->getTypes(); + $innerType = count($types) === 2 ? $types[1] : $types[0]; + if (is_array($rawValue)) { + $innerValues = []; + foreach ($rawValue as $key => $value) { + $innerValues[$key] = self::resolveAnnotatedValue($innerType, $value); + } + return array_key_exists(0, $rawValue) + ? new ($type->getFqsen()->__toString())($innerValues) + : App::makeWith($type->getFqsen()->__toString(), $innerValues); + } + $value = self::resolveAnnotatedValue($innerType, $rawValue); + return new ($type->getFqsen()->__toString())($value); + } + if ($type instanceof Boolean) { return boolval($rawValue); } @@ -73,44 +83,70 @@ class ValueFactory } if ($type instanceof Object_) { - return self::constructObject($type->getFqsen()->__toString(), $rawValue); + return self::make($type->getFqsen()->__toString(), $rawValue); } return $rawValue; } - public static function resolveValue(mixed $rawValue, ?Type $type, ReflectionParameter $reflection): mixed + public static function resolveDeclaredTypeValue(ReflectionNamedType $parameter, mixed $rawValue): mixed { - if ($reflection->allowsNull() && is_null($rawValue)) { - return null; - } + return match ($parameter->getName()) { + 'string' => $rawValue, + 'bool' => boolval($rawValue), + 'int' => intval($rawValue), + 'float' => floatval($rawValue), + 'array' => $rawValue, + default => self::make($parameter->getName(), $rawValue), - $castWithAttrs = $reflection->getAttributes(CastWith::class); - if ($withCast = $reflection->getAttributes(CastWith::class)[0] ?? null) { - $caster = $withCast->newInstance()->class; - return App::call("$caster@cast", ['value' => $rawValue]); - } + }; + } - if (!is_null($type)) { - return self::resolveTypedValue($rawValue, $type); - } + public static function make(string $class, array $input): object + { + $parameters = ReflectionHelper::getParametersMeta($class); + $arguments = []; + foreach ($parameters as $parameter) { + $name = $parameter->reflection->getName(); - $reflectedType = $reflection->getType(); - if ($reflectedType instanceof ReflectionNamedType && $name = $reflectedType->getName()) { - if ($caster = Config::getCaster($name)) { - return App::call($caster, ['value' => $rawValue]); + $parameterArgs = empty($parameter->reflection->getAttributes(Flat::class)) ? ($input[$name] ?? null) : $input; + + if (is_null($parameterArgs)) { + if ($parameter->reflection->allowsNull()) { + $arguments[$name] = null; + } + continue; } - return match ($name) { - 'string' => $rawValue, - 'bool' => boolval($rawValue), - 'int' => intval($rawValue), - 'float' => floatval($rawValue), - 'array' => $rawValue, - default => self::constructObject($name, $rawValue), - }; - } + if ($caster = $parameter->reflection->getAttributes(CastWith::class)[0] ?? null) { + $caster = $caster->newInstance()->class; + $arguments[$name] = App::call("$caster@cast", ['data' => $parameterArgs]); + continue; + } - return $rawValue; + $parameterClass = $parameter->reflection->getClass()?->getName(); + + $type = $parameter->tag?->getType(); + if (empty($parameterClass) && $type instanceof Object_) { + $parameterClass = $type->getFqsen(); + } + if (!empty($parameterClass) && $caster = config('dto.cast.' . $parameterClass, null)) { + $arguments[$name] = App::call($caster, ['data' => $parameterArgs]); + continue; + } + + if ($parameter->tag instanceof Param) { + $arguments[$name] = self::resolveAnnotatedValue($type, $parameterArgs); + continue; + } + + if ($parameter->reflection->getType() instanceof ReflectionNamedType) { + $arguments[$name] = self::resolveDeclaredTypeValue($parameter->reflection->getType(), $parameterArgs); + continue; + } + + $arguments[$name] = $parameterArgs; + } + return App::makeWith($class, $arguments); } } diff --git a/src/config/dto.php b/src/config/dto.php deleted file mode 100644 index bb8ae6e..0000000 --- a/src/config/dto.php +++ /dev/null @@ -1,21 +0,0 @@ - [], - 'rules' => [ - Collection::class => CollectionFactory::rules(...), - ], - 'log' => [ - 'logger' => NullLogger::class, - 'internal' => LogLevel::WARNING, - 'rules' => LogLevel::DEBUG, - 'input' => LogLevel::DEBUG, - 'raw_input' => LogLevel::DEBUG, - 'validation_errors' => LogLevel::INFO, - ], -]; diff --git a/tests/Casters/CasterTest.php b/tests/Casters/CasterTest.php index c8e6a59..93fc5ce 100644 --- a/tests/Casters/CasterTest.php +++ b/tests/Casters/CasterTest.php @@ -17,8 +17,8 @@ describe('caster priority', function () { }); it('uses CastWith attribute over global config caster', function () { - $globalCaster = function (mixed $value): SimpleValue { - return new SimpleValue($value['value'] * 3); + $globalCaster = function (mixed $data): SimpleValue { + return new SimpleValue($data * 3); }; config(['dto.cast.' . SimpleValue::class => $globalCaster]); @@ -30,16 +30,16 @@ describe('caster priority', function () { }); it('falls back to global config caster when no CastWith attribute', function () { - $globalCaster = function (mixed $value): SimpleValue { - return new SimpleValue($value['value'] * 3); + $globalCaster = function (mixed $data): SimpleValue { + return new SimpleValue($data['value'] * 3); }; config(['dto.cast.' . SimpleValue::class => $globalCaster]); $object = WithGlobalCaster::fromArray([ - 'value' => ['value' => 5], + 'simple' => ['value' => 5], ]); - expect($object->value->value)->toBe(15); // 5 * 3 + expect($object->simple->value)->toBe(15); // 5 * 3 }); it('falls back to default construction when no caster exists', function () { diff --git a/tests/Casters/SimpleValueCaster.php b/tests/Casters/SimpleValueCaster.php index 05319c5..fe8e0aa 100644 --- a/tests/Casters/SimpleValueCaster.php +++ b/tests/Casters/SimpleValueCaster.php @@ -6,9 +6,9 @@ namespace Tests\Casters; class SimpleValueCaster { - public function cast(mixed $value): SimpleValue + public function cast(mixed $data): SimpleValue { - return new SimpleValue($value['value'] * 2); + return new SimpleValue($data['value'] * 2); } public static function rules(): array diff --git a/tests/Casters/WithGlobalCaster.php b/tests/Casters/WithGlobalCaster.php index 98b39ef..d829310 100644 --- a/tests/Casters/WithGlobalCaster.php +++ b/tests/Casters/WithGlobalCaster.php @@ -11,6 +11,6 @@ readonly class WithGlobalCaster use DataObject; public function __construct( - public SimpleValue $value, + public SimpleValue $simple, ) {} } diff --git a/tests/Classes/CarbonPeriodMapper.php b/tests/Classes/CarbonPeriodMapper.php index 6d7d8d4..4edee01 100644 --- a/tests/Classes/CarbonPeriodMapper.php +++ b/tests/Classes/CarbonPeriodMapper.php @@ -7,9 +7,9 @@ use Illuminate\Support\Carbon; class CarbonPeriodMapper { - public function cast(mixed $value): CarbonPeriodImmutable + public function cast(mixed $data): CarbonPeriodImmutable { - return new CarbonPeriodImmutable(Carbon::parse($value['start']), Carbon::parse($value['end'])); + return new CarbonPeriodImmutable(Carbon::parse($data['start']), Carbon::parse($data['end'])); } public static function rules(): array diff --git a/tests/Flattening/Classes/BasicRoot.php b/tests/Flattening/Classes/BasicRoot.php deleted file mode 100644 index 299f858..0000000 --- a/tests/Flattening/Classes/BasicRoot.php +++ /dev/null @@ -1,12 +0,0 @@ -make(BasicRoot::class); - expect($rules)->toMatchArray([ - 'text' => ['required'], - 'value' => ['required', 'numeric'], - ]); - }); -}); diff --git a/tests/Rules/RulesTest.php b/tests/Rules/RulesTest.php index fca9610..5f7ed9f 100644 --- a/tests/Rules/RulesTest.php +++ b/tests/Rules/RulesTest.php @@ -15,7 +15,7 @@ use Tests\Rules\WithOverwriteRules; 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, '', ''); + $rules = RuleFactory::infer($parameters, ''); expect($rules)->toBe([ 'value' => ['required', 'numeric'], @@ -24,7 +24,7 @@ describe('rules array shape', function () { it('returns inferred rules shape regardless of OverwriteRules attribute', function () { $parameters = ReflectionHelper::getParametersMeta(WithOverwriteRules::class); - $rules = RuleFactory::infer($parameters, '', ''); + $rules = RuleFactory::infer($parameters, ''); expect($rules)->toBe([ 'value' => ['required', 'numeric'], @@ -93,30 +93,8 @@ describe('rules overwrite', function () { }); it('does not enforce inferred required rule when overwritten', function () { - $object = WithOverwriteRules::fromArray([]); - - expect($object)->toBeInstanceOf(WithOverwriteRules::class); - }); - - it('does not enforce inferred numeric rule when overwritten', function () { $rules = WithOverwriteRules::rules(); expect($rules)->toHaveKey('value'); expect($rules['value'])->toBe(['numeric', 'max:20']); }); }); - -describe('empty rules overwrite', function () { - it('allows any value when rules are empty with OverwriteRules', function () { - $object = WithEmptyOverwriteRules::fromArray([ - 'value' => 999, - ]); - - expect($object->value)->toBe(999); - }); - - it('allows missing value when rules are empty with OverwriteRules', function () { - $object = WithEmptyOverwriteRules::fromArray([]); - - expect($object)->toBeInstanceOf(WithEmptyOverwriteRules::class); - }); -}); 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/TestCase.php b/tests/TestCase.php index b18ec3c..ae817b2 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,15 +2,11 @@ namespace Tests; -use Icefox\DTO\DataObjectServiceProvider; -use Orchestra\Testbench\TestCase as BaseTestCase; +use Illuminate\Contracts\Config\Repository; +use Monolog\Formatter\JsonFormatter; +use Orchestra\Testbench\Concerns\WithWorkbench; -abstract class TestCase extends BaseTestCase +abstract class TestCase extends \Orchestra\Testbench\TestCase { - protected function getPackageProviders($app) - { - return [ - DataObjectServiceProvider::class, - ]; - } + use WithWorkbench; } diff --git a/workbench/bootstrap/app.php b/workbench/bootstrap/app.php deleted file mode 100644 index f7a5caa..0000000 --- a/workbench/bootstrap/app.php +++ /dev/null @@ -1,7 +0,0 @@ -create(); diff --git a/workbench/config/dto.php b/workbench/config/dto.php new file mode 100644 index 0000000..c89046d --- /dev/null +++ b/workbench/config/dto.php @@ -0,0 +1,15 @@ + [ + 'channel' => 'dto', + 'context' => [ + 'rules' => LogLevel::NOTICE, + 'input' => LogLevel::INFO, + 'casts' => LogLevel::INFO, + 'internals' => LogLevel::DEBUG, + ], + ], +]; diff --git a/workbench/config/logging.php b/workbench/config/logging.php new file mode 100644 index 0000000..f111b97 --- /dev/null +++ b/workbench/config/logging.php @@ -0,0 +1,16 @@ + env('LOG_CHANNEL', 'single'), + 'channels' => [ + 'dto' => [ + 'driver' => 'single', + 'path' => getcwd() . '/logs/dto.log', + 'level' => 'debug', + 'replace_placeholders' => true, + 'formatter' => JsonFormatter::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 + + +