diff --git a/composer.json b/composer.json index b415c1c..ad9b9d4 100644 --- a/composer.json +++ b/composer.json @@ -11,9 +11,7 @@ "pestphp/pest": "^4.4", "phpstan/phpstan": "^2.1", "friendsofphp/php-cs-fixer": "^3.94", - "orchestra/testbench": "^9.16", - "pestphp/pest-plugin-laravel": "^4.0", - "laravel/pail": "^1.2" + "orchestra/testbench": "^9.16" }, "license": "GPL-2.0-only", "autoload": { @@ -23,10 +21,7 @@ }, "autoload-dev": { "psr-4": { - "Tests\\": "tests/", - "Workbench\\App\\": "workbench/app/", - "Workbench\\Database\\Factories\\": "workbench/database/factories/", - "Workbench\\Database\\Seeders\\": "workbench/database/seeders/" + "Tests\\": "tests/" } }, "authors": [ @@ -39,26 +34,5 @@ "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 d018f04..8b91f11 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": "0ecb4cd71aa6ab475ed1db205f47b632", + "content-hash": "1d9eef5574135e39ab7eaa6beae3fdad", "packages": [ { "name": "brick/math", @@ -7992,80 +7992,6 @@ ], "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 49574dd..ee80124 100644 --- a/flake.nix +++ b/flake.nix @@ -16,20 +16,10 @@ system: let pkgs = nixpkgs.legacyPackages.${system}; - php = ( - pkgs.php.withExtensions ( - { enabled, all }: - enabled - ++ [ - all.pcntl - all.xdebug - ] - ) - ); in { devShells.default = pkgs.mkShell { - packages = [ + packages = with pkgs; [ php php.packages.composer ]; diff --git a/phpstan.neon.dist b/phpstan.neon.dist index e746411..79b90ad 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -1,4 +1,5 @@ parameters: paths: - src + - tests level: 5 diff --git a/src/Attributes/Flat.php b/src/Attributes/Flat.php deleted file mode 100644 index 27d73ad..0000000 --- a/src/Attributes/Flat.php +++ /dev/null @@ -1,12 +0,0 @@ - static::rulesIlluminateCollection(...), - default => null, - }; - } - - - /** - * @return array - */ - private static function rulesIlluminateCollection(ParameterMeta $parameter, RuleFactory $factory): array - { - if (is_null($parameter->tag)) { - return []; - } - - $type = $parameter->tag->getType(); - if (!$type instanceof Generic) { - return []; - } - - $subtypes = $type->getTypes(); - - if (count($subtypes) == 0) { - return ['' => ['array']]; - } - - $subtype = count($subtypes) == 1 ? $subtypes[0] : $subtypes[1]; - - return $factory->mergeRules( - ['' => ['array']], - $factory->getRulesFromDocBlock($subtype, '.*'), - ); - } -} diff --git a/src/DataObject.php b/src/DataObject.php index 95a7235..9807b35 100644 --- a/src/DataObject.php +++ b/src/DataObject.php @@ -4,20 +4,109 @@ 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 { - return DataObjectFactory::fromRequest(static::class, $request); + $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); } /** * @param array $input */ - public static function fromArray(array $input): ?static + public static function fromArray(array $input): static { - return DataObjectFactory::fromArray(static::class, $input); + $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]); } } diff --git a/src/DataObjectFactory.php b/src/DataObjectFactory.php deleted file mode 100644 index adbf200..0000000 --- a/src/DataObjectFactory.php +++ /dev/null @@ -1,81 +0,0 @@ -route() instanceof Route ? $request->route()->parameters() : []; - return static::fromArray($class, $request->input(), $routeParameters); - } - - /** - * @param class-string $class - * @param array $input - * @param array $routeParameters - */ - public static function fromArray(string $class, array $input, array $routeParameters): ?object - { - $logger = new Log(); - $parameters = ReflectionHelper::getParametersMeta($class); - foreach ($parameters as $parameter) { - $parameterName = $parameter->reflection->getName(); - - foreach ($parameter->reflection->getAttributes(FromRouteParameter::class) as $fromRouteParameter) { - if ($value = $routeParameters[$fromRouteParameter->newInstance()->name] ?? null) { - $input[$parameterName] = $value; - continue 2; - } - } - - foreach ($parameter->reflection->getAttributes(FromInput::class) as $attr) { - if ($value = $input[$attr->newInstance()->name] ?? null) { - $input[$parameterName] = $value; - continue 2; - } - } - - if ($value = $input[$parameterName] ?? null) { - $input[$parameterName] = $value; - continue; - } - - // if ($parameter->reflection->isDefaultValueAvailable()) { - // $input[$parameterName] = $parameter->reflection->getDefaultValue(); - // continue; - // } - } - $logger->inputRaw($input); - - $rules = (new RuleFactory($logger))->make($class); - - $validator = method_exists($class, 'withValidator') - ? App::call("$class::withValidator", ['data' => $input, 'rules' => $rules]) - : App::makeWith(Validator::class, ['data' => $input, 'rules' => $rules]); - - if ($validator->fails()) { - $logger->validationErrors($validator->errors()->toArray()); - if (method_exists($class, 'fails')) { - return App::call("$class::fails", ['validator' => $validator ]); - } - throw new ValidationException($validator); - } - - return ValueFactory::make($class, $validator->validated()); - } -} diff --git a/src/DataObjectServiceProvider.php b/src/DataObjectServiceProvider.php new file mode 100644 index 0000000..b92cd00 --- /dev/null +++ b/src/DataObjectServiceProvider.php @@ -0,0 +1,27 @@ +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 bfd0fe0..5696dad 100644 --- a/src/Factories/CollectionFactory.php +++ b/src/Factories/CollectionFactory.php @@ -2,22 +2,24 @@ 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(ParameterMeta $parameter, RuleFactory $factory): array + public static function rules(ReflectionParameter $parameter, ?Type $type): array { - if (is_null($parameter->tag)) { + if (is_null($type)) { return []; } - $type = $parameter->tag->getType(); if (!$type instanceof Generic) { return []; } @@ -25,14 +27,14 @@ class CollectionFactory $subtypes = $type->getTypes(); if (count($subtypes) == 0) { - return ['' => ['array']]; + return []; } $subtype = count($subtypes) == 1 ? $subtypes[0] : $subtypes[1]; - return $factory->mergeRules( + return array_merge( ['' => ['array']], - $factory->getRulesFromDocBlock($subtype, '.*'), + RuleFactory::getRulesFromDocBlock($subtype, '.*'), ); } } diff --git a/src/InputFactory.php b/src/InputFactory.php deleted file mode 100644 index 7109eee..0000000 --- a/src/InputFactory.php +++ /dev/null @@ -1,41 +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/ReflectionHelper.php b/src/ReflectionHelper.php deleted file mode 100644 index 5c92a22..0000000 --- a/src/ReflectionHelper.php +++ /dev/null @@ -1,48 +0,0 @@ - - */ - 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 9aa1da5..623ebc9 100644 --- a/src/Support/RuleFactory.php +++ b/src/Support/RuleFactory.php @@ -4,41 +4,42 @@ declare(strict_types=1); namespace Icefox\DTO\Support; -use Icefox\DTO\Attributes\Flat; -use Icefox\DTO\Attributes\OverwriteRules; -use Icefox\DTO\Config; +use Icefox\DTO\Attributes\FromMapper; 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_; -final class RuleFactory +class RuleFactory { + protected static array $cache = []; + /** * @return array> */ - public function getRulesFromDocBlock( + public static function getRulesFromDocBlock( Type $type, string $prefix, ): array { $rules = []; if ($type instanceof Nullable) { $rules[$prefix] = ['nullable']; - $type = $type->getActualType(); + $rules = array_merge($rules, self::getRulesFromDocBlock($type->getActualType(), $prefix)); } else { $rules[$prefix] = ['required']; } @@ -47,31 +48,64 @@ final class RuleFactory $rules[$prefix][] = 'array'; $valueType = $type->getValueType(); - $rules = $this->mergeRules($rules, $this->getRulesFromDocBlock($valueType, $prefix . '.*')); + $rules = array_merge($rules, self::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 = ReflectionHelper::getParametersMeta($type->getFqsen()->__toString()); - $rules = $this->mergeRules($rules, $this->infer($paramsSub, $prefix)); + $paramsSub = self::getParametersMeta($type->getFqsen()->__toString()); + $rules = array_merge( + $rules, + self::buildRules($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 function infer(array $parameters, string $basePrefix): array + public static function buildRules(array $parameters, string $prefix): array { $rules = []; foreach ($parameters as $parameter) { - $prefix = $basePrefix - . (empty($basePrefix) ? '' : '.') - . (empty($parameter->reflection->getAttributes(Flat::class)) ? $parameter->reflection->getName() : ''); - foreach ($this->buildParameterRule($parameter, $prefix) as $key => $newRules) { + foreach (self::buildParameterRule($parameter, $prefix) as $key => $newRules) { $rules[$key] = $newRules; } } @@ -81,23 +115,22 @@ final class RuleFactory /** * @return array> */ - public function buildParameterRule(ParameterMeta $parameter, string $prefix): array + public static function buildParameterRule(ParameterMeta $parameter, string $prefix): array { $type = $parameter->reflection->getType(); + $root = $prefix . $parameter->reflection->getName(); if (empty($type)) { - return [$prefix => $parameter->reflection->isOptional() ? ['sometimes'] : ['required']]; + return [$root => $parameter->reflection->isOptional() ? ['sometimes'] : ['required']]; } - $rules = [$prefix => []]; - if (!empty($prefix)) { - if ($parameter->reflection->isOptional()) { - $rules[$prefix][] = 'sometimes'; - } elseif ($type->allowsNull()) { - $rules[$prefix][] = 'nullable'; - } else { - $rules[$prefix][] = 'required'; - } + $rules = [$root => []]; + if ($parameter->reflection->isOptional()) { + $rules[$root][] = 'sometimes'; + } elseif ($type->allowsNull()) { + $rules[$root][] = 'nullable'; + } else { + $rules[$root][] = 'required'; } if ($type instanceof ReflectionUnionType) { @@ -106,96 +139,53 @@ final class RuleFactory } if ($type instanceof ReflectionNamedType && $name = $type->getName()) { - if ($globalRules = Config::getRules($name)) { - foreach ($globalRules($parameter, $this) as $scopedPrefix => $values) { - $realPrefix = $prefix . $scopedPrefix; - $rules[$realPrefix] = array_merge($rules[$realPrefix] ?? [], $values); + 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))); } - return $rules; } - if ($name === 'string') { } elseif ($name === 'bool') { - $rules[$prefix][] = 'boolean'; + $rules[$root][] = 'boolean'; } elseif ($name === 'int' || $name === 'float') { - $rules[$prefix][] = 'numeric'; + $rules[$root][] = 'numeric'; } elseif ($name === 'array') { - $rules[$prefix][] = 'array'; + $rules[$root][] = 'array'; } elseif (enum_exists($name)) { $ref = new ReflectionClass($name); if ($ref->isSubclassOf(BackedEnum::class)) { - $rules[$prefix][] = Rule::enum($name); + $rules[$root][] = Rule::enum($name); } } else { - $paramsSub = ReflectionHelper::getParametersMeta($type->getName()); - $rules = $this->mergeRules($rules, $this->infer($paramsSub, $prefix)); + $paramsSub = self::getParametersMeta($type->getName()); + $rules = array_merge( + $rules, + self::buildRules($paramsSub, $root . '.'), + ); } } + 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 = $this->getRulesFromDocBlock( + $docblockRules = self::getRulesFromDocBlock( $parameter->tag->getType(), - $prefix, + $prefix . $parameter->reflection->getName(), ); - $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; + foreach ($docblockRules as $key => &$values) { + $rules[$key] = array_values(array_unique(array_merge($rules[$key] ?? [], $values))); } } - 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; + return $rules; } } diff --git a/src/Support/ValueFactory.php b/src/Support/ValueFactory.php index 2781ae2..8229b35 100644 --- a/src/Support/ValueFactory.php +++ b/src/Support/ValueFactory.php @@ -4,15 +4,9 @@ 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; @@ -26,50 +20,42 @@ class ValueFactory { public static function constructObject(string $className, mixed $rawValue): object { - if ($mapper = Config::getCaster($className)) { + if ($mapper = config('dto.mappers.' . $className, null)) { 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); + if (is_array($rawValue)) { + return App::makeWith($className, $rawValue); } - // Associative arrays leverage Laravel service container - return App::makeWith($className, $rawValue); + return new $className($rawValue); } - public static function resolveAnnotatedValue(Type $type, mixed $rawValue): mixed + public static function resolveTypedValue(mixed $rawValue, Type $type): 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::resolveAnnotatedValue($innerType, $value); + $result[$key] = self::resolveTypedValue($value, $innerType); } 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); } @@ -83,70 +69,33 @@ class ValueFactory } if ($type instanceof Object_) { - return self::make($type->getFqsen()->__toString(), $rawValue); + return self::constructObject($type->getFqsen()->__toString(), $rawValue); } return $rawValue; } - public static function resolveDeclaredTypeValue(ReflectionNamedType $parameter, mixed $rawValue): mixed + public static function resolveValue(mixed $rawValue, ?Type $type, ReflectionParameter $reflection): mixed { - return match ($parameter->getName()) { - 'string' => $rawValue, - 'bool' => boolval($rawValue), - 'int' => intval($rawValue), - 'float' => floatval($rawValue), - 'array' => $rawValue, - default => self::make($parameter->getName(), $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; - } - - 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; + if ($reflection->allowsNull() && is_null($rawValue)) { + return null; } - return App::makeWith($class, $arguments); + + 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), + }; + } + return $rawValue; + } + + return self::resolveTypedValue($rawValue, $type); } } diff --git a/src/config/dto.php b/src/config/dto.php new file mode 100644 index 0000000..634b846 --- /dev/null +++ b/src/config/dto.php @@ -0,0 +1,14 @@ + [ + Collection::class => CollectionFactory::rules(...), + ], + 'default' => [ + 'factories' => [ + ], + ], +]; diff --git a/tests/Casters/CasterTest.php b/tests/Casters/CasterTest.php deleted file mode 100644 index 93fc5ce..0000000 --- a/tests/Casters/CasterTest.php +++ /dev/null @@ -1,71 +0,0 @@ - []]); - }); - - it('uses CastWith attribute over global config caster', function () { - $globalCaster = function (mixed $data): SimpleValue { - return new SimpleValue($data * 3); - }; - config(['dto.cast.' . SimpleValue::class => $globalCaster]); - - $object = WithSpecificCaster::fromArray([ - 'value' => ['value' => 5], - ]); - - expect($object->value->value)->toBe(10); // 5 * 2 - }); - - it('falls back to global config caster when no CastWith attribute', function () { - $globalCaster = function (mixed $data): SimpleValue { - return new SimpleValue($data['value'] * 3); - }; - config(['dto.cast.' . SimpleValue::class => $globalCaster]); - - $object = WithGlobalCaster::fromArray([ - 'simple' => ['value' => 5], - ]); - - expect($object->simple->value)->toBe(15); // 5 * 3 - }); - - it('falls back to default construction when no caster exists', function () { - $object = WithoutCaster::fromArray([ - 'value' => ['value' => 5], - ]); - expect($object)->toBeInstanceOf(WithoutCaster::class); - }); -}); - -describe('caster with rules', function () { - beforeEach(function () { - config(['dto.cast' => []]); - }); - - it('validates input using caster rules before casting', function () { - expect(fn() => WithSpecificCaster::fromArray([ - 'value' => [], - ]))->toThrow(ValidationException::class); - }); - - it('accepts valid input and casts correctly', function () { - $object = WithSpecificCaster::fromArray([ - 'value' => ['value' => 10], - ]); - - expect($object->value->value)->toBe(20); // 10 * 2 - }); -}); diff --git a/tests/Casters/SimpleValue.php b/tests/Casters/SimpleValue.php deleted file mode 100644 index 1f08683..0000000 --- a/tests/Casters/SimpleValue.php +++ /dev/null @@ -1,10 +0,0 @@ - ['required', 'numeric'], - ]; - } -} diff --git a/tests/Casters/WithGlobalCaster.php b/tests/Casters/WithGlobalCaster.php deleted file mode 100644 index d829310..0000000 --- a/tests/Casters/WithGlobalCaster.php +++ /dev/null @@ -1,16 +0,0 @@ -json(['errors' => $validator->errors()], 422) - ); - } -} diff --git a/tests/Classes/ObjectWithoutMapper.php b/tests/Classes/ObjectWithoutMapper.php index 82342e3..4c37d78 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 899b8fe..31e0bd3 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\CastWith; +use Icefox\DTO\Attributes\FromMapper; use Icefox\DTO\DataObject; readonly class WithMapperObject @@ -13,7 +13,7 @@ readonly class WithMapperObject use DataObject; public function __construct( - #[CastWith(CarbonPeriodMapper::class)] + #[FromMapper(CarbonPeriodMapper::class)] public CarbonPeriodImmutable $period, ) {} } diff --git a/tests/DataObjectTest.php b/tests/DataObjectTest.php index b68a4b7..9514994 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 = (new RuleFactory(new Log()))->make(PrimitiveData::class); + $rules = RuleFactory::buildRules(RuleFactory::getParametersMeta(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 = (new RuleFactory(new Log()))->make(OptionalData::class); + $rules = RuleFactory::buildRules(RuleFactory::getParametersMeta(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 = (new RuleFactory(new Log()))->make(OptionalNullableData::class); + $rules = RuleFactory::buildRules(RuleFactory::getParametersMeta(OptionalNullableData::class), ''); expect($rules)->toMatchArray([ 'string' => ['required'], 'int' => ['nullable', 'numeric'], @@ -93,7 +93,10 @@ describe('nullable data', function () { describe('reference other DataObject', function () { it('creates recursive rules', function () { - $rules = (new RuleFactory(new Log()))->make(RecursiveDataObject::class); + $rules = RuleFactory::buildRules( + RuleFactory::getParametersMeta(RecursiveDataObject::class), + '', + ); expect($rules)->toMatchArray([ 'string' => ['required'], 'extra.string' => ['required'], @@ -106,7 +109,7 @@ describe('reference other DataObject', function () { describe('primitive array', function () { it('creates array rules', function () { - $rules = (new RuleFactory(new Log()))->make(ArrayDataObject::class); + $rules = RuleFactory::buildRules(RuleFactory::getParametersMeta(ArrayDataObject::class), ''); expect($rules)->toMatchArray([ 'values' => ['required', 'array'], 'values.*' => ['required', 'numeric'], @@ -117,7 +120,10 @@ describe('primitive array', function () { describe('object array', function () { it('creates array rules', function () { - $rules = (new RuleFactory(new Log()))->make(CollectionDataObject::class); + $rules = RuleFactory::buildRules( + RuleFactory::getParametersMeta(CollectionDataObject::class), + '', + ); expect($rules)->toMatchArray([ 'values' => ['required', 'array'], 'values.*' => ['required'], @@ -133,7 +139,7 @@ describe('can map input names', function () { it('creates rules with property names', function () { - $rules = (new RuleFactory(new Log()))->make(FromInputObject::class); + $rules = RuleFactory::buildRules(RuleFactory::getParametersMeta(FromInputObject::class), ''); expect($rules)->toMatchArray([ 'text' => ['required' ], 'standard' => ['required', 'numeric'], @@ -192,6 +198,11 @@ 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 deleted file mode 100644 index 11b2ca1..0000000 --- a/tests/FailedValidation/FailsMethodTest.php +++ /dev/null @@ -1,108 +0,0 @@ - 0, - 'float' => 3.14, - 'bool' => true, - ]); - })->toThrow(ValidationException::class); - }); - - it('returns null when fails() returns null', function () { - $result = FailsReturnsNull::fromArray([ - 'int' => 0, - ]); - - expect($result)->toBeNull(); - }); - - it('returns static instance when fails() returns an object', function () { - $result = FailsReturnsDefault::fromArray([ - 'int' => 0, - ]); - - expect($result)->toBeInstanceOf(FailsReturnsDefault::class); - expect($result->string)->toBe('default_value'); - expect($result->int)->toBe(42); - }); -}); - -describe('HTTP request handling', function () { - - beforeEach(function () { - \Illuminate\Support\Facades\Route::post('/test-validation-exception', function () { - PrimitiveData::fromArray([ - 'int' => 0, - 'float' => 3.14, - 'bool' => true, - ]); - return response()->json(['success' => true]); - }); - - \Illuminate\Support\Facades\Route::post('/test-http-response-exception', function () { - FailsWithHttpResponse::fromArray([ - 'int' => 0, - ]); - return response()->json(['success' => true]); - }); - - \Illuminate\Support\Facades\Route::post('/test-validation-exception-html', function () { - PrimitiveData::fromArray([ - 'int' => 0, - 'float' => 3.14, - 'bool' => true, - ]); - return response('success'); - }); - }); - - it('returns 422 with errors when ValidationException is thrown in JSON request', function () { - $response = $this->postJson('/test-validation-exception', [ - 'int' => 0, - 'float' => 3.14, - 'bool' => true, - ]); - - $response->assertStatus(422); - $response->assertJsonValidationErrors(['string']); - }); - - it('returns custom JSON response when HttpResponseException is thrown', function () { - $response = $this->postJson('/test-http-response-exception', [ - 'int' => 0, - ]); - - $response->assertStatus(422); - $response->assertJsonStructure(['errors']); - $response->assertJsonFragment([ - 'errors' => [ - 'string' => ['The string field is required.'], - ], - ]); - }); - - it('redirects back with session errors when ValidationException is thrown in text/html request', function () { - $response = $this->post('/test-validation-exception-html', [ - 'int' => 0, - 'float' => 3.14, - 'bool' => true, - ], [ - 'Accept' => 'text/html', - ]); - - $response->assertRedirect(); - $response->assertSessionHasErrors(['string']); - }); -}); diff --git a/tests/Logging/CustomLogger.php b/tests/Logging/CustomLogger.php deleted file mode 100644 index c4bff3d..0000000 --- a/tests/Logging/CustomLogger.php +++ /dev/null @@ -1,36 +0,0 @@ -logs[] = [ - 'level' => $level, - 'message' => $message, - 'context' => $context, - ]; - } - - public function hasLog(string $level, string $contains): bool - { - foreach ($this->logs as $log) { - if ($log['level'] === $level && str_contains($log['message'], $contains)) { - return true; - } - } - return false; - } - - public function clear(): void - { - $this->logs = []; - } -} diff --git a/tests/Logging/LogTest.php b/tests/Logging/LogTest.php deleted file mode 100644 index 6847938..0000000 --- a/tests/Logging/LogTest.php +++ /dev/null @@ -1,281 +0,0 @@ -set('dto.log.logger', NullLogger::class); - }); - - it('uses NullLogger as fallback when logger config is null', function () { - config()->set('dto.log.logger', null); - $log = new Log(); - expect($log->logger)->toBeInstanceOf(NullLogger::class); - }); - - it('uses NullLogger as fallback when logger config is invalid', function () { - config()->set('dto.log.logger', 'NonExistentLoggerClass'); - $log = new Log(); - expect($log->logger)->toBeInstanceOf(NullLogger::class); - }); - - it('instantiates logger from class name via Laravel container', function () { - config()->set('dto.log.logger', CustomLogger::class); - $log = new Log(); - expect($log->logger)->toBeInstanceOf(CustomLogger::class); - }); - - it('uses logger object directly when provided', function () { - $customLogger = new CustomLogger(); - config()->set('dto.log.logger', $customLogger); - $log = new Log(); - expect($log->logger)->toBe($customLogger); - }); - - it('invokes callable to get logger instance', function () { - config()->set('dto.log.logger', function () { - return new CustomLogger(); - }); - - $log = new Log(); - - expect($log->logger)->toBeInstanceOf(CustomLogger::class); - }); -}); - -describe('log level configuration', function () { - - beforeEach(function () { - $this->customLogger = new CustomLogger(); - config()->set('dto.log.logger', $this->customLogger); - }); - - afterEach(function () { - config()->set('dto.log.logger', NullLogger::class); - config()->set('dto.log.rules', LogLevel::DEBUG); - config()->set('dto.log.input', LogLevel::DEBUG); - config()->set('dto.log.raw_input', LogLevel::DEBUG); - config()->set('dto.log.validation_errors', LogLevel::INFO); - }); - - it('logs rules at configured level', function () { - config()->set('dto.log.rules', LogLevel::INFO); - - $log = new Log(); - $log->rules(['field' => ['required']]); - - expect($this->customLogger->hasLog(LogLevel::INFO, 'field'))->toBeTrue(); - }); - - it('logs input at configured level', function () { - config()->set('dto.log.input', LogLevel::INFO); - - $log = new Log(); - $log->input(['field' => 'value']); - - expect($this->customLogger->hasLog(LogLevel::INFO, 'value'))->toBeTrue(); - }); - - it('logs raw input at configured level', function () { - config()->set('dto.log.raw_input', LogLevel::ERROR); - - $log = new Log(); - $log->inputRaw(['field' => 'raw_value']); - - expect($this->customLogger->hasLog(LogLevel::ERROR, 'raw_value'))->toBeTrue(); - }); - - it('logs validation errors at configured level', function () { - config()->set('dto.log.validation_errors', LogLevel::ERROR); - - $log = new Log(); - $log->validationErrors(['field' => ['The field is required.']]); - - expect($this->customLogger->hasLog(LogLevel::ERROR, 'required'))->toBeTrue(); - }); - - it('allows different log levels for each log type', function () { - config()->set('dto.log.rules', LogLevel::DEBUG); - config()->set('dto.log.input', LogLevel::INFO); - config()->set('dto.log.raw_input', LogLevel::INFO); - - $log = new Log(); - - $log->rules(['rules_field' => ['required']]); - $log->input(['input_field' => 'value']); - $log->inputRaw(['raw_field' => 'raw_value']); - - expect($this->customLogger->hasLog(LogLevel::DEBUG, 'rules_field'))->toBeTrue(); - expect($this->customLogger->hasLog(LogLevel::INFO, 'input_field'))->toBeTrue(); - expect($this->customLogger->hasLog(LogLevel::INFO, 'raw_field'))->toBeTrue(); - }); - - it('defaults to DEBUG level when not configured', function () { - config()->set('dto.log.rules', null); - config()->set('dto.log.input', null); - config()->set('dto.log.raw_input', null); - - $customLogger = new CustomLogger(); - config()->set('dto.log.logger', $customLogger); - - $log = new Log(); - - $log->rules(['field' => ['required']]); - $log->input(['field' => 'value']); - $log->inputRaw(['field' => 'raw_value']); - - expect(count($customLogger->logs))->toBe(3); - expect($customLogger->logs[0]['level'])->toBe(LogLevel::DEBUG); - expect($customLogger->logs[1]['level'])->toBe(LogLevel::DEBUG); - expect($customLogger->logs[2]['level'])->toBe(LogLevel::DEBUG); - }); -}); - -describe('integration with DataObject', function () { - - beforeEach(function () { - $this->customLogger = new CustomLogger(); - config()->set('dto.log.logger', $this->customLogger); - config()->set('dto.log.rules', LogLevel::DEBUG); - config()->set('dto.log.input', LogLevel::DEBUG); - config()->set('dto.log.raw_input', LogLevel::DEBUG); - }); - - afterEach(function () { - config()->set('dto.log.logger', NullLogger::class); - }); - - it('logs raw input during fromArray execution', function () { - PrimitiveData::fromArray([ - 'string' => 'test', - 'int' => 42, - 'float' => 3.14, - 'bool' => true, - ]); - - expect($this->customLogger->hasLog(LogLevel::DEBUG, 'raw_input'))->toBeFalse(); - expect($this->customLogger->hasLog(LogLevel::DEBUG, 'string'))->toBeTrue(); - expect($this->customLogger->hasLog(LogLevel::DEBUG, '42'))->toBeTrue(); - }); - - it('logs rules during fromArray execution', function () { - PrimitiveData::fromArray([ - 'string' => 'test', - 'int' => 42, - 'float' => 3.14, - 'bool' => true, - ]); - - expect($this->customLogger->hasLog(LogLevel::DEBUG, 'required'))->toBeTrue(); - }); - - it('logs processed input during fromArray execution', function () { - PrimitiveData::fromArray([ - 'string' => 'test', - 'int' => 42, - 'float' => 3.14, - 'bool' => true, - ]); - - expect($this->customLogger->hasLog(LogLevel::DEBUG, 'test'))->toBeTrue(); - }); - - it('captures all three log types during successful fromArray', function () { - PrimitiveData::fromArray([ - 'string' => 'integration_test', - 'int' => 123, - 'float' => 9.99, - 'bool' => false, - ]); - - $rawInputLogged = false; - $rulesLogged = false; - $inputLogged = false; - - foreach ($this->customLogger->logs as $log) { - if (str_contains($log['message'], 'string')) { - $rawInputLogged = true; - } - if (str_contains($log['message'], 'required')) { - $rulesLogged = true; - } - if (str_contains($log['message'], 'integration_test')) { - $inputLogged = true; - } - } - - expect($rawInputLogged)->toBeTrue('Raw input should be logged'); - expect($rulesLogged)->toBeTrue('Rules should be logged'); - expect($inputLogged)->toBeTrue('Processed input should be logged'); - }); - - it('logs even when validation fails', function () { - try { - PrimitiveData::fromArray([ - 'int' => 42, - 'float' => 3.14, - 'bool' => true, - ]); - } catch (\Illuminate\Validation\ValidationException $e) { - // Expected - } - - expect($this->customLogger->hasLog(LogLevel::DEBUG, 'required'))->toBeTrue(); - expect($this->customLogger->hasLog(LogLevel::DEBUG, '42'))->toBeTrue(); - }); - - it('logs validation errors when validation fails', function () { - config()->set('dto.log.validation_errors', LogLevel::ERROR); - - try { - PrimitiveData::fromArray([ - 'int' => 42, - 'float' => 3.14, - 'bool' => true, - ]); - } catch (\Illuminate\Validation\ValidationException $e) { - // Expected - } - - expect($this->customLogger->hasLog(LogLevel::ERROR, 'string'))->toBeTrue(); - expect($this->customLogger->hasLog(LogLevel::ERROR, 'required'))->toBeTrue(); - }); -}); - -describe('logging with NullLogger', function () { - - it('does not throw when logging with NullLogger', function () { - config()->set('dto.log.logger', NullLogger::class); - - $log = new Log(); - - expect(function () use ($log) { - $log->rules(['field' => ['required']]); - $log->input(['field' => 'value']); - $log->inputRaw(['field' => 'raw_value']); - })->not->toThrow(\Throwable::class); - }); - - it('does not affect DataObject behavior when using NullLogger', function () { - config()->set('dto.log.logger', NullLogger::class); - - $object = PrimitiveData::fromArray([ - 'string' => 'test', - 'int' => 42, - 'float' => 3.14, - 'bool' => true, - ]); - - expect($object)->toBeInstanceOf(PrimitiveData::class); - expect($object->string)->toBe('test'); - }); -}); diff --git a/tests/Rules/RulesTest.php b/tests/Rules/RulesTest.php deleted file mode 100644 index 5f7ed9f..0000000 --- a/tests/Rules/RulesTest.php +++ /dev/null @@ -1,100 +0,0 @@ -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 deleted file mode 100644 index e743bed..0000000 --- a/tests/Rules/WithEmptyOverwriteRules.php +++ /dev/null @@ -1,23 +0,0 @@ - ['max:20'], - ]; - } -} diff --git a/tests/Rules/WithOverwriteRules.php b/tests/Rules/WithOverwriteRules.php deleted file mode 100644 index 21b2145..0000000 --- a/tests/Rules/WithOverwriteRules.php +++ /dev/null @@ -1,25 +0,0 @@ - ['numeric', 'max:20'], - ]; - } -} diff --git a/tests/RulesTest.php b/tests/RulesTest.php deleted file mode 100644 index 8ffeff5..0000000 --- a/tests/RulesTest.php +++ /dev/null @@ -1,161 +0,0 @@ -make(BasicPrimitives::class))->toBe([ - 'text' => ['required'], - 'number' => ['required', 'numeric'], - 'flag' => ['required', 'boolean'], - 'items' => ['nullable', 'array'], - 'floating' => ['sometimes', 'numeric'], - ]); -}); - -readonly class AnnotatedArray -{ - /** - * @param array $items - */ - public function __construct(public array $items) {} -} -test('annotated array', function () { - expect(RuleFactory::instance()->make(AnnotatedArray::class))->toBe([ - 'items' => ['required', 'array'], - 'items.*' => ['required', 'numeric'], - ]); -}); - -readonly class AnnotatedArrayNullableValue -{ - /** - * @param array $items - */ - public function __construct(public array $items) {} -} -test('annotated array with nullable items', function () { - expect(RuleFactory::instance()->make(AnnotatedArrayNullableValue::class))->toBe([ - 'items' => ['required', 'array'], - 'items.*' => ['nullable', 'numeric'], - ]); -}); - -readonly class PlainLeaf -{ - public function __construct(public string $name) {} -} -readonly class PlainRoot -{ - public function __construct(public int $value, public PlainLeaf $leaf) {} -} - -test('plain nesting', function () { - expect(RuleFactory::instance()->make(PlainRoot::class))->toBe([ - 'value' => ['required', 'numeric'], - 'leaf' => ['required'], - 'leaf.name' => ['required'], - ]); -}); - - -readonly class AnnotatedArrayItem -{ - public function __construct(public int $value) {} -} - -readonly class AnnotatedArrayObject -{ - /** - * @param ?array $items - */ - public function __construct(public ?array $items) {} -} - -test('annotated array with object', function () { - expect(RuleFactory::instance()->make(AnnotatedArrayObject::class))->toBe([ - 'items' => ['nullable', 'array'], - 'items.*' => ['required'], - 'items.*.value' => ['required', 'numeric'], - ]); -}); - -readonly class FlattenedLeaf -{ - public function __construct(public ?bool $flag) {} -} - -readonly class NotFlattenedLeaf -{ - public function __construct(public string $description) {} -} - -readonly class FlattenedNode -{ - public function __construct( - public string $id, - public NotFlattenedLeaf $leaf, - #[Flat] - public FlattenedLeaf $squish, - public int $level = 1, - ) {} -} - -readonly class FlattenedRoot -{ - public function __construct( - public int $value, - #[Flat] - public FlattenedNode $node, - ) {} -} - -test('flattened basic', function () { - expect(RuleFactory::instance()->make(FlattenedRoot::class))->toBe([ - 'value' => ['required', 'numeric'], - 'id' => ['required' ], - 'leaf' => ['required'], - 'leaf.description' => ['required'], - 'flag' => ['nullable', 'boolean'], - 'level' => ['sometimes', 'numeric'], - ]); -}); - -readonly class AnnotatedCollectionItem -{ - public function __construct(public int $value) {} -} - -readonly class AnnotatedCollection -{ - /** - * @param Collection $group - */ - public function __construct(public Collection $group) {} -} - -test('annotated collection', function () { - expect(RuleFactory::instance()->make(AnnotatedCollection::class))->toBe([ - 'group' => ['required', 'array'], - 'group.*' => ['required'], - 'group.*.value' => ['required', 'numeric'], - ]); -}); diff --git a/tests/TestCase.php b/tests/TestCase.php index ae817b2..b18ec3c 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,11 +2,15 @@ namespace Tests; -use Illuminate\Contracts\Config\Repository; -use Monolog\Formatter\JsonFormatter; -use Orchestra\Testbench\Concerns\WithWorkbench; +use Icefox\DTO\DataObjectServiceProvider; +use Orchestra\Testbench\TestCase as BaseTestCase; -abstract class TestCase extends \Orchestra\Testbench\TestCase +abstract class TestCase extends BaseTestCase { - use WithWorkbench; + protected function getPackageProviders($app) + { + return [ + DataObjectServiceProvider::class, + ]; + } } diff --git a/workbench/bootstrap/app.php b/workbench/bootstrap/app.php new file mode 100644 index 0000000..f7a5caa --- /dev/null +++ b/workbench/bootstrap/app.php @@ -0,0 +1,7 @@ +create(); diff --git a/workbench/config/dto.php b/workbench/config/dto.php deleted file mode 100644 index c89046d..0000000 --- a/workbench/config/dto.php +++ /dev/null @@ -1,15 +0,0 @@ - [ - '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 deleted file mode 100644 index f111b97..0000000 --- a/workbench/config/logging.php +++ /dev/null @@ -1,16 +0,0 @@ - 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 deleted file mode 100644 index 0788ab5..0000000 --- a/workbench/phpunit.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - tests - - - - - app - - -