diff --git a/composer.json b/composer.json index ad9b9d4..b415c1c 100644 --- a/composer.json +++ b/composer.json @@ -11,7 +11,9 @@ "pestphp/pest": "^4.4", "phpstan/phpstan": "^2.1", "friendsofphp/php-cs-fixer": "^3.94", - "orchestra/testbench": "^9.16" + "orchestra/testbench": "^9.16", + "pestphp/pest-plugin-laravel": "^4.0", + "laravel/pail": "^1.2" }, "license": "GPL-2.0-only", "autoload": { @@ -21,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": [ @@ -34,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 8b91f11..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": "1d9eef5574135e39ab7eaa6beae3fdad", + "content-hash": "0ecb4cd71aa6ab475ed1db205f47b632", "packages": [ { "name": "brick/math", @@ -7992,6 +7992,80 @@ ], "time": "2025-08-20T13:10:51+00:00" }, + { + "name": "pestphp/pest-plugin-laravel", + "version": "v4.0.0", + "source": { + "type": "git", + "url": "https://github.com/pestphp/pest-plugin-laravel.git", + "reference": "e12a07046b826a40b1c8632fd7b80d6b8d7b628e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/pestphp/pest-plugin-laravel/zipball/e12a07046b826a40b1c8632fd7b80d6b8d7b628e", + "reference": "e12a07046b826a40b1c8632fd7b80d6b8d7b628e", + "shasum": "" + }, + "require": { + "laravel/framework": "^11.45.2|^12.25.0", + "pestphp/pest": "^4.0.0", + "php": "^8.3.0" + }, + "require-dev": { + "laravel/dusk": "^8.3.3", + "orchestra/testbench": "^9.13.0|^10.5.0", + "pestphp/pest-dev-tools": "^4.0.0" + }, + "type": "library", + "extra": { + "pest": { + "plugins": [ + "Pest\\Laravel\\Plugin" + ] + }, + "laravel": { + "providers": [ + "Pest\\Laravel\\PestServiceProvider" + ] + } + }, + "autoload": { + "files": [ + "src/Autoload.php" + ], + "psr-4": { + "Pest\\Laravel\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "The Pest Laravel Plugin", + "keywords": [ + "framework", + "laravel", + "pest", + "php", + "test", + "testing", + "unit" + ], + "support": { + "source": "https://github.com/pestphp/pest-plugin-laravel/tree/v4.0.0" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + } + ], + "time": "2025-08-20T12:46:37+00:00" + }, { "name": "pestphp/pest-plugin-mutate", "version": "v4.0.1", 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/phpstan.neon.dist b/phpstan.neon.dist index 79b90ad..e746411 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -1,5 +1,4 @@ parameters: paths: - src - - tests level: 5 diff --git a/src/Attributes/FromMapper.php b/src/Attributes/CastWith.php similarity index 93% rename from src/Attributes/FromMapper.php rename to src/Attributes/CastWith.php index ffdce99..6d544c5 100644 --- a/src/Attributes/FromMapper.php +++ b/src/Attributes/CastWith.php @@ -7,7 +7,7 @@ namespace Icefox\DTO\Attributes; use Attribute; #[Attribute(Attribute::TARGET_PARAMETER)] -class FromMapper +class CastWith { /** * @param class-string $class diff --git a/src/Attributes/Flat.php b/src/Attributes/Flat.php new file mode 100644 index 0000000..27d73ad --- /dev/null +++ b/src/Attributes/Flat.php @@ -0,0 +1,12 @@ + 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 9807b35..95a7235 100644 --- a/src/DataObject.php +++ b/src/DataObject.php @@ -4,109 +4,20 @@ declare(strict_types=1); namespace Icefox\DTO; -use Icefox\DTO\Attributes\FromInput; -use Icefox\DTO\Attributes\FromMapper; -use Icefox\DTO\Attributes\FromRouteParameter; -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); } /** * @param array $input */ - public static function fromArray(array $input): static + public static function fromArray(array $input): ?static { - $parameters = RuleFactory::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; - } - } - - $rules = RuleFactory::buildRules($parameters, ''); - $validator = static::withValidator($input, $rules); - - if ($validator->fails()) { - $exception = new ValidationException($validator); - throw $exception; - } - - $mappedInput = []; - foreach ($parameters as $parameter) { - $parameterName = $parameter->reflection->getName(); - - if ($mapper = array_first($parameter->reflection->getAttributes(FromMapper::class))) { - $value = App::call( - [App::make($mapper->newInstance()->class), 'map'], - ['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, - ); - } - return App::make(static::class, $mappedInput); - } - - public static function rules(): array - { - return []; - } - - /** - * @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/ReflectionHelper.php b/src/ReflectionHelper.php new file mode 100644 index 0000000..5c92a22 --- /dev/null +++ b/src/ReflectionHelper.php @@ -0,0 +1,48 @@ + + */ + public static function getParametersMeta(string $class): array + { + if (array_key_exists($class, self::$cache)) { + return self::$cache[$class]; + } + + $reflection = new ReflectionClass($class); + $constructor = $reflection->getConstructor(); + try { + $docblockParams = (DocBlockFactory::createInstance())->create( + $constructor->getDocComment(), + (new ContextFactory())->createFromReflector($constructor), + )->getTagsByName('param'); + } catch (\Exception) { + $docblockParams = []; + } + self::$cache[$class] = array_map( + fn(ReflectionParameter $p) => new ParameterMeta( + $p, + array_find( + $docblockParams, + fn(Tag $tag) => $tag instanceof Param ? $tag->getVariableName() == $p->getName() : false, + ), + ), + $constructor->getParameters(), + ); + return self::$cache[$class]; + } +} + diff --git a/src/Support/RuleFactory.php b/src/Support/RuleFactory.php index 623ebc9..9aa1da5 100644 --- a/src/Support/RuleFactory.php +++ b/src/Support/RuleFactory.php @@ -4,42 +4,41 @@ declare(strict_types=1); namespace Icefox\DTO\Support; -use Icefox\DTO\Attributes\FromMapper; +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; +use Psr\Log\LoggerInterface; use ReflectionClass; use ReflectionNamedType; -use ReflectionParameter; use ReflectionUnionType; use BackedEnum; -use phpDocumentor\Reflection\DocBlockFactory; -use phpDocumentor\Reflection\DocBlock\Tag; use phpDocumentor\Reflection\DocBlock\Tags\Param; use phpDocumentor\Reflection\Type; use phpDocumentor\Reflection\Types\AbstractList; use phpDocumentor\Reflection\Types\Boolean; -use phpDocumentor\Reflection\Types\ContextFactory; use phpDocumentor\Reflection\Types\Float_; use phpDocumentor\Reflection\Types\Integer; use phpDocumentor\Reflection\Types\Nullable; use phpDocumentor\Reflection\Types\Object_; -class RuleFactory +final class RuleFactory { - protected static array $cache = []; - /** * @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']; } @@ -48,64 +47,31 @@ 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'; } elseif ($type instanceof Float_ || $type instanceof Integer) { $rules[$prefix][] = 'numeric'; } elseif ($type instanceof Object_) { - $paramsSub = self::getParametersMeta($type->getFqsen()->__toString()); - $rules = array_merge( - $rules, - self::buildRules($paramsSub, $prefix . '.'), - ); + $paramsSub = ReflectionHelper::getParametersMeta($type->getFqsen()->__toString()); + $rules = $this->mergeRules($rules, $this->infer($paramsSub, $prefix)); } return $rules; } - /** - * @param class-string $class - * @return array - */ - public static function getParametersMeta(string $class): array - { - if (array_key_exists($class, self::$cache)) { - return self::$cache[$class]; - } - - $reflection = new ReflectionClass($class); - $constructor = $reflection->getConstructor(); - try { - $docblockParams = (DocBlockFactory::createInstance())->create( - $constructor->getDocComment(), - (new ContextFactory())->createFromReflector($constructor), - )->getTagsByName('param'); - } catch (\Exception) { - $docblockParams = []; - } - self::$cache[$class] = array_map( - fn(ReflectionParameter $p) => new ParameterMeta( - $p, - array_find( - $docblockParams, - fn(Tag $tag) => $tag instanceof Param ? $tag->getVariableName() == $p->getName() : false, - ), - ), - $constructor->getParameters(), - ); - return self::$cache[$class]; - } - /** * @param array $parameters * @return array> */ - public static function buildRules(array $parameters, string $prefix): array + public function infer(array $parameters, string $basePrefix): array { $rules = []; foreach ($parameters as $parameter) { - foreach (self::buildParameterRule($parameter, $prefix) as $key => $newRules) { + $prefix = $basePrefix + . (empty($basePrefix) ? '' : '.') + . (empty($parameter->reflection->getAttributes(Flat::class)) ? $parameter->reflection->getName() : ''); + foreach ($this->buildParameterRule($parameter, $prefix) as $key => $newRules) { $rules[$key] = $newRules; } } @@ -115,22 +81,23 @@ 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(); - $root = $prefix . $parameter->reflection->getName(); if (empty($type)) { - return [$root => $parameter->reflection->isOptional() ? ['sometimes'] : ['required']]; + return [$prefix => $parameter->reflection->isOptional() ? ['sometimes'] : ['required']]; } - $rules = [$root => []]; - if ($parameter->reflection->isOptional()) { - $rules[$root][] = 'sometimes'; - } elseif ($type->allowsNull()) { - $rules[$root][] = 'nullable'; - } else { - $rules[$root][] = 'required'; + $rules = [$prefix => []]; + if (!empty($prefix)) { + if ($parameter->reflection->isOptional()) { + $rules[$prefix][] = 'sometimes'; + } elseif ($type->allowsNull()) { + $rules[$prefix][] = 'nullable'; + } else { + $rules[$prefix][] = 'required'; + } } if ($type instanceof ReflectionUnionType) { @@ -139,53 +106,96 @@ class RuleFactory } if ($type instanceof ReflectionNamedType && $name = $type->getName()) { - if ($globalRules = config('dto.rules.' . $name)) { - foreach ($globalRules($parameter->reflection, $parameter->tag->getType()) as $scopedPrefix => $values) { - $realPrefix = $root . $scopedPrefix; - $rules[$realPrefix] = array_values(array_unique(array_merge($rules[$realPrefix] ?? [], $values))); + if ($globalRules = Config::getRules($name)) { + foreach ($globalRules($parameter, $this) as $scopedPrefix => $values) { + $realPrefix = $prefix . $scopedPrefix; + $rules[$realPrefix] = array_merge($rules[$realPrefix] ?? [], $values); } + return $rules; } + if ($name === 'string') { } elseif ($name === 'bool') { - $rules[$root][] = 'boolean'; + $rules[$prefix][] = 'boolean'; } elseif ($name === 'int' || $name === 'float') { - $rules[$root][] = 'numeric'; + $rules[$prefix][] = 'numeric'; } elseif ($name === 'array') { - $rules[$root][] = 'array'; + $rules[$prefix][] = 'array'; } elseif (enum_exists($name)) { $ref = new ReflectionClass($name); if ($ref->isSubclassOf(BackedEnum::class)) { - $rules[$root][] = Rule::enum($name); + $rules[$prefix][] = Rule::enum($name); } } else { - $paramsSub = self::getParametersMeta($type->getName()); - $rules = array_merge( - $rules, - self::buildRules($paramsSub, $root . '.'), - ); + $paramsSub = ReflectionHelper::getParametersMeta($type->getName()); + $rules = $this->mergeRules($rules, $this->infer($paramsSub, $prefix)); } } - foreach ($parameter->reflection->getAttributes(FromMapper::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) ? $root : ($root . '.' . $key); - $rules[$path] = array_values(array_unique(array_merge($rules[$path] ?? [], $value))); - } - } - } if ($parameter->tag instanceof Param) { - $docblockRules = self::getRulesFromDocBlock( + $docblockRules = $this->getRulesFromDocBlock( $parameter->tag->getType(), - $prefix . $parameter->reflection->getName(), + $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 LoggerInterface $log) {} + + /** + * @param class-string $class + * @return array> + */ + public function make(string $class): array + { + $parameters = ReflectionHelper::getParametersMeta($class); + + $classReflection = new ReflectionClass($class); + $hasRulesMethod = $classReflection->hasMethod('rules'); + + $customRules = $hasRulesMethod ? App::call("$class::rules", []) : []; + + + if ($hasRulesMethod && !empty($classReflection->getMethod('rules')->getAttributes(OverwriteRules::class))) { + $rules = $customRules; + } else { + $inferredRules = RuleFactory::infer($parameters, ''); + $rules = $this->mergeRules($inferredRules, $customRules); + } + $this->log->info('Constructed rules for class ' . $class, $rules); + return $rules; + } + + /** + * @param array> $first + * @param array> $second + * @return array> + */ + public function mergeRules(array $first, array $second): array + { + $merged = $first; + foreach ($second as $key => $rules) { + if (isset($merged[$key])) { + $merged[$key] = array_values(array_unique(array_merge($merged[$key], $rules))); + } else { + $merged[$key] = $rules; + } + } + 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 8229b35..2781ae2 100644 --- a/src/Support/ValueFactory.php +++ b/src/Support/ValueFactory.php @@ -4,9 +4,15 @@ 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; @@ -20,42 +26,50 @@ class ValueFactory { public static function constructObject(string $className, mixed $rawValue): object { - if ($mapper = config('dto.mappers.' . $className, null)) { + if ($mapper = Config::getCaster($className)) { return $mapper($rawValue); } - if (is_array($rawValue)) { - return App::makeWith($className, $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); } - return new $className($rawValue); + // Associative arrays leverage Laravel service container + 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); } @@ -69,33 +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), - if (is_null($type)) { - $reflectedType = $reflection->getType(); - if ($reflectedType instanceof ReflectionNamedType && $name = $reflectedType->getName()) { - return match ($name) { - 'string' => $rawValue, - 'bool' => boolval($rawValue), - 'int' => intval($rawValue), - 'float' => floatval($rawValue), - 'array' => $rawValue, - default => self::constructObject($name, $rawValue), - }; + }; + } + + public static function make(string $class, array $input): object + { + $parameters = ReflectionHelper::getParametersMeta($class); + $arguments = []; + foreach ($parameters as $parameter) { + $name = $parameter->reflection->getName(); + + $parameterArgs = empty($parameter->reflection->getAttributes(Flat::class)) ? ($input[$name] ?? null) : $input; + + if (is_null($parameterArgs)) { + if ($parameter->reflection->allowsNull()) { + $arguments[$name] = null; + } + continue; } - return $rawValue; - } - return self::resolveTypedValue($rawValue, $type); + if ($caster = $parameter->reflection->getAttributes(CastWith::class)[0] ?? null) { + $caster = $caster->newInstance()->class; + $arguments[$name] = App::call("$caster@cast", ['data' => $parameterArgs]); + continue; + } + + $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 634b846..0000000 --- a/src/config/dto.php +++ /dev/null @@ -1,14 +0,0 @@ - [ - Collection::class => CollectionFactory::rules(...), - ], - 'default' => [ - 'factories' => [ - ], - ], -]; 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 @@ +json(['errors' => $validator->errors()], 422) + ); + } +} diff --git a/tests/Classes/ObjectWithoutMapper.php b/tests/Classes/ObjectWithoutMapper.php index 4c37d78..82342e3 100644 --- a/tests/Classes/ObjectWithoutMapper.php +++ b/tests/Classes/ObjectWithoutMapper.php @@ -4,8 +4,8 @@ declare(strict_types=1); namespace Tests\Classes; +use Carbon\Carbon; use Icefox\DTO\DataObject; -use Illuminate\Support\Carbon; readonly class ObjectWithoutMapper { diff --git a/tests/Classes/WithMapperObject.php b/tests/Classes/WithMapperObject.php index 31e0bd3..899b8fe 100644 --- a/tests/Classes/WithMapperObject.php +++ b/tests/Classes/WithMapperObject.php @@ -5,7 +5,7 @@ declare(strict_types=1); namespace Tests\Classes; use Carbon\CarbonPeriodImmutable; -use Icefox\DTO\Attributes\FromMapper; +use Icefox\DTO\Attributes\CastWith; use Icefox\DTO\DataObject; readonly class WithMapperObject @@ -13,7 +13,7 @@ readonly class WithMapperObject use DataObject; public function __construct( - #[FromMapper(CarbonPeriodMapper::class)] + #[CastWith(CarbonPeriodMapper::class)] public CarbonPeriodImmutable $period, ) {} } diff --git a/tests/DataObjectTest.php b/tests/DataObjectTest.php index 9514994..b68a4b7 100644 --- a/tests/DataObjectTest.php +++ b/tests/DataObjectTest.php @@ -2,12 +2,12 @@ namespace Tests; +use Icefox\DTO\Log; use Icefox\DTO\Support\RuleFactory; use Illuminate\Validation\ValidationException; use Tests\Classes\ArrayDataObject; use Tests\Classes\CollectionDataObject; use Tests\Classes\FromInputObject; -use Tests\Classes\ObjectWithoutMapper; use Tests\Classes\OptionalData; use Tests\Classes\OptionalNullableData; use Tests\Classes\PrimitiveData; @@ -16,7 +16,7 @@ use Tests\Classes\WithMapperObject; describe('primitive data test', function () { it('creates required rules', function () { - $rules = RuleFactory::buildRules(RuleFactory::getParametersMeta(PrimitiveData::class), ''); + $rules = (new RuleFactory(new Log()))->make(PrimitiveData::class); expect($rules)->toMatchArray([ 'string' => ['required'], 'int' => ['required', 'numeric'], @@ -42,7 +42,7 @@ describe('primitive data test', function () { describe('optional data', function () { it('creates optional rules', function () { - $rules = RuleFactory::buildRules(RuleFactory::getParametersMeta(OptionalData::class), ''); + $rules = (new RuleFactory(new Log()))->make(OptionalData::class); expect($rules)->toMatchArray([ 'string' => ['sometimes'], 'int' => ['sometimes', 'numeric'], @@ -63,7 +63,7 @@ describe('optional data', function () { describe('nullable data', function () { it('creates nullable rules', function () { - $rules = RuleFactory::buildRules(RuleFactory::getParametersMeta(OptionalNullableData::class), ''); + $rules = (new RuleFactory(new Log()))->make(OptionalNullableData::class); expect($rules)->toMatchArray([ 'string' => ['required'], 'int' => ['nullable', 'numeric'], @@ -93,10 +93,7 @@ describe('nullable data', function () { describe('reference other DataObject', function () { it('creates recursive rules', function () { - $rules = RuleFactory::buildRules( - RuleFactory::getParametersMeta(RecursiveDataObject::class), - '', - ); + $rules = (new RuleFactory(new Log()))->make(RecursiveDataObject::class); expect($rules)->toMatchArray([ 'string' => ['required'], 'extra.string' => ['required'], @@ -109,7 +106,7 @@ describe('reference other DataObject', function () { describe('primitive array', function () { it('creates array rules', function () { - $rules = RuleFactory::buildRules(RuleFactory::getParametersMeta(ArrayDataObject::class), ''); + $rules = (new RuleFactory(new Log()))->make(ArrayDataObject::class); expect($rules)->toMatchArray([ 'values' => ['required', 'array'], 'values.*' => ['required', 'numeric'], @@ -120,10 +117,7 @@ describe('primitive array', function () { describe('object array', function () { it('creates array rules', function () { - $rules = RuleFactory::buildRules( - RuleFactory::getParametersMeta(CollectionDataObject::class), - '', - ); + $rules = (new RuleFactory(new Log()))->make(CollectionDataObject::class); expect($rules)->toMatchArray([ 'values' => ['required', 'array'], 'values.*' => ['required'], @@ -139,7 +133,7 @@ describe('can map input names', function () { it('creates rules with property names', function () { - $rules = RuleFactory::buildRules(RuleFactory::getParametersMeta(FromInputObject::class), ''); + $rules = (new RuleFactory(new Log()))->make(FromInputObject::class); expect($rules)->toMatchArray([ 'text' => ['required' ], 'standard' => ['required', 'numeric'], @@ -198,11 +192,6 @@ test('failed validation throws ValidationException', function () { })->throws(ValidationException::class); -test('tries to resolve without mapper', function () { - $object = ObjectWithoutMapper::fromArray(['date' => '1990-04-01']); - expect($object->date->isSameDay('1990-04-01'))->toBeTrue(); -})->group('object-without-mapper'); - test('creates collection', function () { $object = CollectionDataObject::fromArray([ 'values' => [ 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/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 new file mode 100644 index 0000000..5f7ed9f --- /dev/null +++ b/tests/Rules/RulesTest.php @@ -0,0 +1,100 @@ +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'], + ]); + }); +}); + +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([]); + }); +}); + +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); + }); +}); + +describe('rules overwrite', function () { + it('uses only custom rules when OverwriteRules attribute is present', function () { + $object = WithOverwriteRules::fromArray([ + 'value' => 10, + ]); + + 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/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 + + +