Compare commits

..

7 commits

Author SHA1 Message Date
icefox
a5b80681c9
use Data namespace 2026-03-07 11:27:12 -03:00
icefox
71d49def6b
abstract 2026-02-27 11:17:21 -03:00
icefox
30706c3521
http tests 2026-02-27 11:14:42 -03:00
icefox
b827038df3
refactor, tests 2026-02-25 17:47:17 -03:00
icefox
fc46fe20ee
input mapping 2026-02-25 16:03:00 -03:00
icefox
bba10b455f
tests 2026-02-25 12:29:47 -03:00
icefox
6b1a385292
refactor 2026-02-23 21:35:08 -03:00
51 changed files with 1248 additions and 1635 deletions

View file

@ -1,6 +1,7 @@
{ {
"name": "icefox/dto", "name": "icefox/dto",
"type": "library", "type": "library",
"version": "0.0.1",
"require": { "require": {
"laravel/framework": "^11.0", "laravel/framework": "^11.0",
"psr/log": "^3.0", "psr/log": "^3.0",
@ -18,15 +19,12 @@
"license": "GPL-2.0-only", "license": "GPL-2.0-only",
"autoload": { "autoload": {
"psr-4": { "psr-4": {
"Icefox\\DTO\\": "src/" "Icefox\\Data\\": "src/"
} }
}, },
"autoload-dev": { "autoload-dev": {
"psr-4": { "psr-4": {
"Tests\\": "tests/", "Tests\\": "tests/"
"Workbench\\App\\": "workbench/app/",
"Workbench\\Database\\Factories\\": "workbench/database/factories/",
"Workbench\\Database\\Seeders\\": "workbench/database/seeders/"
} }
}, },
"authors": [ "authors": [

View file

@ -2,8 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace Icefox\DTO\Attributes; namespace Icefox\Data\Attributes;
use Attribute; use Attribute;
#[Attribute(Attribute::TARGET_PARAMETER)] #[Attribute(Attribute::TARGET_PARAMETER)]

View file

@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace Icefox\DTO\Attributes; namespace Icefox\Data\Attributes;
use Attribute; use Attribute;

View file

@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace Icefox\DTO\Attributes; namespace Icefox\Data\Attributes;
use Attribute; use Attribute;

View file

@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace Icefox\DTO\Attributes; namespace Icefox\Data\Attributes;
use Attribute; use Attribute;

View file

@ -2,11 +2,9 @@
declare(strict_types=1); declare(strict_types=1);
namespace Icefox\DTO\Attributes; namespace Icefox\Data\Attributes;
use Attribute; use Attribute;
#[Attribute(Attribute::TARGET_METHOD)] #[Attribute(Attribute::TARGET_METHOD)]
class OverwriteRules class Overwrite {}
{
}

View file

@ -1,63 +0,0 @@
<?php
declare(strict_types=1);
namespace Icefox\DTO;
use Icefox\DTO\Support\RuleFactory;
use Illuminate\Support\Collection;
use phpDocumentor\Reflection\PseudoTypes\Generic;
class Config
{
/**
* @param class-string $class
**/
public static function getCaster(string $class): ?callable
{
return config('dto.cast.' . $class, null);
}
/**
* @param class-string $class
**/
public static function getRules(string $class): ?callable
{
if ($userDefined = config('dto.rules.' . $class, null)) {
return $userDefined;
}
return match ($class) {
Collection::class => static::rulesIlluminateCollection(...),
default => null,
};
}
/**
* @return array<string,string[]>
*/
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, '.*'),
);
}
}

View file

@ -1,17 +1,16 @@
<?php <?php
namespace Icefox\DTO\Factories; namespace Icefox\Data;
use Icefox\DTO\ParameterMeta; use Icefox\Data\Factories\RuleFactory;
use Icefox\DTO\Support\RuleFactory;
use phpDocumentor\Reflection\PseudoTypes\Generic; use phpDocumentor\Reflection\PseudoTypes\Generic;
class CollectionFactory class CustomHandlers
{ {
/** /**
* @return array<string,string[]> * @return array<string,string[]>
*/ */
public static function rules(ParameterMeta $parameter, RuleFactory $factory): array public static function CollectionRules(ParameterMeta $parameter, RuleFactory $factory): array
{ {
if (is_null($parameter->tag)) { if (is_null($parameter->tag)) {
return []; return [];

View file

@ -2,13 +2,14 @@
declare(strict_types=1); declare(strict_types=1);
namespace Icefox\DTO; namespace Icefox\Data;
use Icefox\Data\Factories\DataObjectFactory;
use Illuminate\Http\Request; use Illuminate\Http\Request;
trait DataObject trait DataObject
{ {
public static function fromRequest(Request $request): mixed public static function fromRequest(Request $request): ?static
{ {
return DataObjectFactory::fromRequest(static::class, $request); return DataObjectFactory::fromRequest(static::class, $request);
} }

View file

@ -1,81 +0,0 @@
<?php
namespace Icefox\DTO;
use Icefox\DTO\Attributes\FromInput;
use Icefox\DTO\Attributes\FromRouteParameter;
use Icefox\DTO\Support\RuleFactory;
use Icefox\DTO\Support\ValueFactory;
use Illuminate\Http\Request;
use Illuminate\Routing\Route;
use Illuminate\Support\Facades\App;
use Illuminate\Validation\ValidationException;
use Illuminate\Validation\Validator;
use ReflectionClass;
class DataObjectFactory
{
/**
* @param class-string $class
*/
public static function fromRequest(string $class, Request $request): ?object
{
$routeParameters = $request->route() instanceof Route ? $request->route()->parameters() : [];
return static::fromArray($class, $request->input(), $routeParameters);
}
/**
* @param class-string $class
* @param array<string,mixed> $input
* @param array<string,mixed> $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());
}
}

View file

@ -0,0 +1,144 @@
<?php
namespace Icefox\Data\Factories;
use Icefox\Data\Attributes\FromInput;
use Icefox\Data\Attributes\FromRouteParameter;
use Icefox\Data\Factories\RuleFactory;
use Icefox\Data\Factories\ValueFactory;
use Icefox\Data\ReflectionHelper;
use Illuminate\Http\Request;
use Illuminate\Routing\Route;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException;
use Illuminate\Validation\Validator;
use Psr\Log\LoggerInterface;
use ReflectionNamedType;
class DataObjectFactory
{
/**
* @param class-string $class
*/
public static function fromRequest(string $class, Request $request): ?object
{
$route = $request->route();
$routeParameters = $route instanceof Route ? $route->parameters() : [];
return static::fromArray($class, $request->input(), $routeParameters);
}
/**
* @param class-string $class
* @param array<string,mixed> $rawInput
* @param array<string,mixed> $routeParameters
*/
public static function fromArray(string $class, array $rawInput, array $routeParameters): ?object
{
$logger = Log::channel('dto');
$defaults = method_exists($class, 'defaults')
? App::call("$class::defaults")
: [];
$mergedInput = array_replace_recursive($defaults, $rawInput);
$input = self::mapInput($class, $mergedInput, $routeParameters, $logger);
$rules = (new RuleFactory($logger))->make($class);
$validator = method_exists($class, 'withValidator')
? App::call("$class::withValidator", ['data' => $input, 'rules' => $rules])
: App::makeWith(Validator::class, ['data' => $input, 'rules' => $rules]);
if ($validator->fails()) {
$logger->warning('validation error', $validator->errors()->toArray());
if (method_exists($class, 'fails')) {
return App::call("$class::fails", ['validator' => $validator ]);
}
throw new ValidationException($validator);
}
return ValueFactory::make($class, $validator->validated());
}
/**
* @param class-string $class
* @param array<string,mixed> $rawInput
* @param array<string,mixed> $routeParameters
* @return array<string,mixed>
*/
public static function mapInput(
string $class,
array $rawInput,
array $routeParameters,
LoggerInterface $logger,
): array {
$input = [];
$parameters = ReflectionHelper::getParametersMeta($class);
foreach ($parameters as $parameter) {
$parameterName = $parameter->reflection->getName();
foreach ($parameter->reflection->getAttributes(FromRouteParameter::class) as $fromRouteParameter) {
if ($value = $routeParameters[$fromRouteParameter->newInstance()->name] ?? null) {
$input[$parameterName] = $value;
continue 2;
}
}
$reflectionType = $parameter->reflection->getType();
$namedType = $reflectionType instanceof ReflectionNamedType ? $reflectionType->getName() : null;
$annotatedType = $parameter->tag?->getType();
$isListType = ReflectionHelper::isListType($parameter);
foreach ($parameter->reflection->getAttributes(FromInput::class) as $attr) {
if ($value = $rawInput[$attr->newInstance()->name] ?? null) {
if ($valueType = ReflectionHelper::getListParameterValueType($parameter->tag)) {
$input[$parameterName] = $isListType
? array_map(
fn($element) => self::mapInput($valueType, $element, $routeParameters, $logger),
$value,
)
: self::mapInput($valueType, $value, $routeParameters, $logger);
} else {
$input[$parameterName] = $value;
}
continue 2;
}
}
if ($valueType = ReflectionHelper::getListParameterValueType($parameter->tag)) {
if (class_exists($valueType)) {
$input[$parameterName] = $isListType
? array_map(
fn($element) => self::mapInput($valueType, $element, $routeParameters, $logger),
$rawInput[$parameterName],
)
: self::mapInput($valueType, $rawInput[$parameterName], $routeParameters, $logger);
continue;
}
if (array_key_exists($parameterName, $rawInput)) {
$input[$parameterName] = $rawInput[$parameterName];
continue;
}
}
if ($reflectionType instanceof ReflectionNamedType) {
if ($reflectionType->isBuiltin()) {
if (array_key_exists($parameterName, $rawInput)) {
$input[$parameterName] = $rawInput[$parameterName];
}
} else {
$input[$parameterName] = self::mapInput($reflectionType->__toString(), $rawInput[$parameterName], $routeParameters, $logger);
}
continue;
}
if (array_key_exists($parameterName, $rawInput)) {
$input[$parameterName] = $rawInput[$parameterName];
}
}
$logger->debug('input', $input);
return $input;
}
}

View file

@ -2,13 +2,13 @@
declare(strict_types=1); declare(strict_types=1);
namespace Icefox\DTO\Support; namespace Icefox\Data\Factories;
use Icefox\DTO\Attributes\Flat; use Icefox\Data\Attributes\Flat;
use Icefox\DTO\Attributes\OverwriteRules; use Icefox\Data\Attributes\Overwrite;
use Icefox\DTO\Config; use Icefox\Data\ParameterMeta;
use Icefox\DTO\ParameterMeta; use Icefox\Data\ReflectionHelper;
use Icefox\DTO\ReflectionHelper; use Icefox\Data\Factories\RuleFactory;
use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
@ -29,7 +29,7 @@ use phpDocumentor\Reflection\Types\Object_;
final class RuleFactory final class RuleFactory
{ {
/** /**
* @return array<string, array<string|Rule>> * @return array<string, array<int, mixed>>
*/ */
public function getRulesFromDocBlock( public function getRulesFromDocBlock(
Type $type, Type $type,
@ -62,7 +62,7 @@ final class RuleFactory
/** /**
* @param array<ParameterMeta> $parameters * @param array<ParameterMeta> $parameters
* @return array<string,array<int,string|Rule>> * @return array<string,array<int,mixed>>
*/ */
public function infer(array $parameters, string $basePrefix): array public function infer(array $parameters, string $basePrefix): array
{ {
@ -79,7 +79,7 @@ final class RuleFactory
} }
/** /**
* @return array<string, array<int, string|Rule>> * @return array<string, array<int, mixed>>
*/ */
public function buildParameterRule(ParameterMeta $parameter, string $prefix): array public function buildParameterRule(ParameterMeta $parameter, string $prefix): array
{ {
@ -106,7 +106,7 @@ final class RuleFactory
} }
if ($type instanceof ReflectionNamedType && $name = $type->getName()) { if ($type instanceof ReflectionNamedType && $name = $type->getName()) {
if ($globalRules = Config::getRules($name)) { if ($globalRules = config('dto.rules.' . $name, null)) {
foreach ($globalRules($parameter, $this) as $scopedPrefix => $values) { foreach ($globalRules($parameter, $this) as $scopedPrefix => $values) {
$realPrefix = $prefix . $scopedPrefix; $realPrefix = $prefix . $scopedPrefix;
$rules[$realPrefix] = array_merge($rules[$realPrefix] ?? [], $values); $rules[$realPrefix] = array_merge($rules[$realPrefix] ?? [], $values);
@ -149,7 +149,7 @@ final class RuleFactory
/** /**
* @param class-string $class * @param class-string $class
* @return array<string,array<int, string>> * @return array<string,array<int, mixed>>
*/ */
public function make(string $class): array public function make(string $class): array
{ {
@ -161,7 +161,7 @@ final class RuleFactory
$customRules = $hasRulesMethod ? App::call("$class::rules", []) : []; $customRules = $hasRulesMethod ? App::call("$class::rules", []) : [];
if ($hasRulesMethod && !empty($classReflection->getMethod('rules')->getAttributes(OverwriteRules::class))) { if ($hasRulesMethod && !empty($classReflection->getMethod('rules')->getAttributes(Overwrite::class))) {
$rules = $customRules; $rules = $customRules;
} else { } else {
$inferredRules = RuleFactory::infer($parameters, ''); $inferredRules = RuleFactory::infer($parameters, '');
@ -172,9 +172,9 @@ final class RuleFactory
} }
/** /**
* @param array<string,array<int, string>> $first * @param array<string,array<int, mixed>> $first
* @param array<string,array<int, string>> $second * @param array<string,array<int, mixed>> $second
* @return array<string,array<int, string>> * @return array<string,array<int, mixed>>
*/ */
public function mergeRules(array $first, array $second): array public function mergeRules(array $first, array $second): array
{ {

View file

@ -2,17 +2,13 @@
declare(strict_types=1); declare(strict_types=1);
namespace Icefox\DTO\Support; namespace Icefox\Data\Factories;
use Icefox\DTO\Attributes\CastWith; use Icefox\Data\Attributes\CastWith;
use Icefox\DTO\Attributes\Flat; use Icefox\Data\Attributes\Flat;
use Icefox\DTO\Config; use Icefox\Data\ReflectionHelper;
use Icefox\DTO\ParameterMeta;
use Icefox\DTO\ReflectionHelper;
use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\App;
use ReflectionNamedType; use ReflectionNamedType;
use ReflectionParameter;
use phpDocumentor\Reflection\DocBlock\Tags\Param;
use phpDocumentor\Reflection\PseudoTypes\Generic; use phpDocumentor\Reflection\PseudoTypes\Generic;
use phpDocumentor\Reflection\Type; use phpDocumentor\Reflection\Type;
use phpDocumentor\Reflection\Types\AbstractList; use phpDocumentor\Reflection\Types\AbstractList;
@ -24,21 +20,6 @@ use phpDocumentor\Reflection\Types\Object_;
class ValueFactory class ValueFactory
{ {
public static function constructObject(string $className, mixed $rawValue): object
{
if ($mapper = Config::getCaster($className)) {
return $mapper($rawValue);
}
// Plain values or numeric arrays are passed as a single parameter to the constructor
if (!is_array($rawValue) || array_key_exists(0, $rawValue)) {
return new $className($className);
}
// Associative arrays leverage Laravel service container
return App::makeWith($className, $rawValue);
}
public static function resolveAnnotatedValue(Type $type, mixed $rawValue): mixed public static function resolveAnnotatedValue(Type $type, mixed $rawValue): mixed
{ {
if ($type instanceof Nullable) { if ($type instanceof Nullable) {
@ -91,17 +72,24 @@ class ValueFactory
public static function resolveDeclaredTypeValue(ReflectionNamedType $parameter, mixed $rawValue): mixed public static function resolveDeclaredTypeValue(ReflectionNamedType $parameter, mixed $rawValue): mixed
{ {
return match ($parameter->getName()) { $type = $parameter->getName();
if (is_a($type, \BackedEnum::class, true)) {
return $type::from($rawValue);
}
return match ($type) {
'string' => $rawValue, 'string' => $rawValue,
'bool' => boolval($rawValue), 'bool' => boolval($rawValue),
'int' => intval($rawValue), 'int' => intval($rawValue),
'float' => floatval($rawValue), 'float' => floatval($rawValue),
'array' => $rawValue, 'array' => $rawValue,
default => self::make($parameter->getName(), $rawValue), default => self::make($type, $rawValue),
}; };
} }
/**
* @param array<string,mixed> $input
*/
public static function make(string $class, array $input): object public static function make(string $class, array $input): object
{ {
$parameters = ReflectionHelper::getParametersMeta($class); $parameters = ReflectionHelper::getParametersMeta($class);
@ -112,9 +100,9 @@ class ValueFactory
$parameterArgs = empty($parameter->reflection->getAttributes(Flat::class)) ? ($input[$name] ?? null) : $input; $parameterArgs = empty($parameter->reflection->getAttributes(Flat::class)) ? ($input[$name] ?? null) : $input;
if (is_null($parameterArgs)) { if (is_null($parameterArgs)) {
if ($parameter->reflection->allowsNull()) { $arguments[$name] = $parameter->reflection->isDefaultValueAvailable()
$arguments[$name] = null; ? $parameter->reflection->getDefaultValue()
} : null;
continue; continue;
} }
@ -128,25 +116,27 @@ class ValueFactory
$type = $parameter->tag?->getType(); $type = $parameter->tag?->getType();
if (empty($parameterClass) && $type instanceof Object_) { if (empty($parameterClass) && $type instanceof Object_) {
$parameterClass = $type->getFqsen(); $parameterClass = $type->getFqsen()?->__toString();
} }
if (!empty($parameterClass) && $caster = config('dto.cast.' . $parameterClass, null)) { if (!empty($parameterClass) && $caster = config('dto.cast.' . $parameterClass, null)) {
$arguments[$name] = App::call($caster, ['data' => $parameterArgs]); $arguments[$name] = App::call($caster, ['data' => $parameterArgs]);
continue; continue;
} }
if ($parameter->tag instanceof Param) { if (!is_null($type)) {
$arguments[$name] = self::resolveAnnotatedValue($type, $parameterArgs); $arguments[$name] = self::resolveAnnotatedValue($type, $parameterArgs);
continue; continue;
} }
if ($parameter->reflection->getType() instanceof ReflectionNamedType) { $reflectionType = $parameter->reflection->getType();
$arguments[$name] = self::resolveDeclaredTypeValue($parameter->reflection->getType(), $parameterArgs); if ($reflectionType instanceof ReflectionNamedType) {
$arguments[$name] = self::resolveDeclaredTypeValue($reflectionType, $parameterArgs);
continue; continue;
} }
$arguments[$name] = $parameterArgs; $arguments[$name] = $parameterArgs;
} }
return App::makeWith($class, $arguments); return new $class(...$arguments);
} }
} }

5
src/IData.php Normal file
View file

@ -0,0 +1,5 @@
<?php
namespace Icefox\Data;
interface IData {}

View file

@ -1,41 +0,0 @@
<?php
namespace Icefox\DTO;
use Icefox\DTO\Attributes\FromInput;
use Icefox\DTO\Attributes\FromRouteParameter;
class InputFactory
{
public function __construct(public readonly Log $log) {}
public function make(string $class): array
{
$map = [];
$parameters = ReflectionHelper::getParametersMeta($class);
foreach ($parameters as $parameter) {
$name = $parameter->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;
}
}

View file

@ -1,6 +1,6 @@
<?php <?php
namespace Icefox\DTO; namespace Icefox\Data;
use ReflectionParameter; use ReflectionParameter;
use phpDocumentor\Reflection\DocBlock\Tags\Param; use phpDocumentor\Reflection\DocBlock\Tags\Param;

View file

@ -0,0 +1,26 @@
<?php
namespace Icefox\Data\Providers;
use Icefox\Data\Factories\DataObjectFactory;
use Icefox\Data\IData;
use Illuminate\Support\ServiceProvider;
class DataObjectServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->publishes([
__DIR__ . '../../workbench/config/dto.php' => config_path('dto.php'),
]);
$this->app->beforeResolving(function ($abstract, $parameters, $app) {
if ($app->has($abstract)) {
return;
}
if (is_subclass_of($abstract, IData::class)) {
$app->bind($abstract, fn($container) => DataObjectFactory::fromRequest($abstract, $container['request']));
}
});
}
}

View file

@ -1,10 +1,13 @@
<?php <?php
namespace Icefox\DTO; namespace Icefox\Data;
use ReflectionNamedType;
use ReflectionParameter; use ReflectionParameter;
use phpDocumentor\Reflection\DocBlock\Tag; use phpDocumentor\Reflection\DocBlock\Tag;
use phpDocumentor\Reflection\DocBlock\Tags\Param; use phpDocumentor\Reflection\DocBlock\Tags\Param;
use phpDocumentor\Reflection\PseudoTypes\Generic;
use phpDocumentor\Reflection\Types\AbstractList;
use phpDocumentor\Reflection\Types\ContextFactory; use phpDocumentor\Reflection\Types\ContextFactory;
use phpDocumentor\Reflection\DocBlockFactory; use phpDocumentor\Reflection\DocBlockFactory;
use ReflectionClass; use ReflectionClass;
@ -44,5 +47,34 @@ class ReflectionHelper
); );
return self::$cache[$class]; return self::$cache[$class];
} }
}
public static function getListParameterValueType(?Param $param): ?string
{
$type = $param?->getType();
if ($type instanceof AbstractList) {
return $type->getValueType()->__toString();
}
if (!$type instanceof Generic) {
return null;
}
$subtypes = $type->getTypes();
return count($subtypes) > 1 ? $subtypes[1]->__toString() : $subtypes[0]->__toString();
}
public static function isListType(ParameterMeta $parameter): bool
{
$reflectionType = $parameter->reflection->getType();
$namedType = $reflectionType instanceof ReflectionNamedType ? $reflectionType->getName() : null;
$annotatedType = $parameter->tag?->getType();
return
$parameter->reflection->isArray()
|| in_array($namedType, config('dto.listTypes', []))
|| in_array($annotatedType?->__toString(), config('dto.listTypes', []))
|| $annotatedType instanceof AbstractList;
}
}

View file

@ -1,71 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests;
use Illuminate\Validation\ValidationException;
use Tests\Casters\SimpleValue;
use Tests\Casters\SimpleValueCaster;
use Tests\Casters\WithGlobalCaster;
use Tests\Casters\WithSpecificCaster;
use Tests\Casters\WithoutCaster;
describe('caster priority', function () {
beforeEach(function () {
config(['dto.cast' => []]);
});
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
});
});

View file

@ -1,10 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Casters;
class SimpleValue
{
public function __construct(public readonly int $value) {}
}

View file

@ -1,20 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Casters;
class SimpleValueCaster
{
public function cast(mixed $data): SimpleValue
{
return new SimpleValue($data['value'] * 2);
}
public static function rules(): array
{
return [
'value' => ['required', 'numeric'],
];
}
}

View file

@ -1,16 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Casters;
use Icefox\DTO\DataObject;
readonly class WithGlobalCaster
{
use DataObject;
public function __construct(
public SimpleValue $simple,
) {}
}

View file

@ -1,18 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Casters;
use Icefox\DTO\Attributes\CastWith;
use Icefox\DTO\DataObject;
readonly class WithSpecificCaster
{
use DataObject;
public function __construct(
#[CastWith(SimpleValueCaster::class)]
public SimpleValue $value,
) {}
}

View file

@ -1,16 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Casters;
use Icefox\DTO\DataObject;
readonly class WithoutCaster
{
use DataObject;
public function __construct(
public SimpleValue $value,
) {}
}

View file

@ -1,16 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Classes;
use Icefox\DTO\DataObject;
readonly class ArrayDataObject
{
use DataObject;
/**
* @param array<int,int> $values
*/
public function __construct(public array $values) {}
}

View file

@ -1,22 +0,0 @@
<?php
namespace Tests\Classes;
use Carbon\CarbonPeriodImmutable;
use Illuminate\Support\Carbon;
class CarbonPeriodMapper
{
public function cast(mixed $data): CarbonPeriodImmutable
{
return new CarbonPeriodImmutable(Carbon::parse($data['start']), Carbon::parse($data['end']));
}
public static function rules(): array
{
return [
'start' => ['required', 'date'],
'end' => ['required', 'date'],
];
}
}

View file

@ -1,17 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Classes;
use Icefox\DTO\DataObject;
use Illuminate\Support\Collection;
readonly class CollectionDataObject
{
use DataObject;
/**
* @param Collection<OptionalNullableData> $values
*/
public function __construct(public Collection $values) {}
}

View file

@ -1,23 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Classes;
use Icefox\DTO\DataObject;
use Illuminate\Validation\Validator;
readonly class FailsReturnsDefault
{
use DataObject;
public function __construct(
public string $string,
public int $int = 42,
) {}
public static function fails(Validator $validator): ?static
{
return new self(string: 'default_value');
}
}

View file

@ -1,23 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Classes;
use Icefox\DTO\DataObject;
use Illuminate\Validation\Validator;
readonly class FailsReturnsNull
{
use DataObject;
public function __construct(
public string $string,
public int $int,
) {}
public static function fails(Validator $validator): ?static
{
return null;
}
}

View file

@ -1,26 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Classes;
use Icefox\DTO\DataObject;
use Illuminate\Http\Exceptions\HttpResponseException;
use Illuminate\Validation\Validator;
readonly class FailsWithHttpResponse
{
use DataObject;
public function __construct(
public string $string,
public int $int,
) {}
public static function fails(Validator $validator): ?static
{
throw new HttpResponseException(
response()->json(['errors' => $validator->errors()], 422)
);
}
}

View file

@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Classes;
use Icefox\DTO\Attributes\FromInput;
use Icefox\DTO\DataObject;
readonly class FromInputObject
{
use DataObject;
public function __construct(
#[FromInput('other_name')]
public string $text,
public int $standard,
) {}
}

View file

@ -1,17 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Classes;
use Carbon\Carbon;
use Icefox\DTO\DataObject;
readonly class ObjectWithoutMapper
{
use DataObject;
public function __construct(
public Carbon $date,
) {}
}

View file

@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Classes;
use Icefox\DTO\DataObject;
readonly class OptionalData
{
use DataObject;
public function __construct(
public string $string = 'xyz',
public float $float = 0.777,
public int $int = 3,
public bool $bool = false,
) {}
}

View file

@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Classes;
use Icefox\DTO\DataObject;
readonly class OptionalNullableData
{
use DataObject;
public function __construct(
public string $string,
public ?int $int,
public float $float = 0.999,
public bool $bool = false,
) {}
}

View file

@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Classes;
use Icefox\DTO\DataObject;
readonly class PrimitiveData
{
use DataObject;
public function __construct(
public string $string,
public int $int,
public float $float,
public bool $bool,
) {}
}

View file

@ -1,17 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Classes;
use Icefox\DTO\DataObject;
readonly class RecursiveDataObject
{
use DataObject;
public function __construct(
public string $string,
public PrimitiveData $extra,
) {}
}

View file

@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Classes;
use Carbon\CarbonPeriodImmutable;
use Icefox\DTO\Attributes\CastWith;
use Icefox\DTO\DataObject;
readonly class WithMapperObject
{
use DataObject;
public function __construct(
#[CastWith(CarbonPeriodMapper::class)]
public CarbonPeriodImmutable $period,
) {}
}

View file

@ -0,0 +1,400 @@
<?php
namespace Tests\DataObject;
use Icefox\Data\Attributes\FromInput;
use Icefox\Data\Attributes\FromRouteParameter;
use Icefox\Data\Factories\DataObjectFactory;
use Illuminate\Support\Collection;
use Psr\Log\NullLogger;
readonly class Element
{
public function __construct(public int $value) {}
}
readonly class Node
{
public function __construct(public Element $element) {}
}
test('basic nested object', function () {
$input = DataObjectFactory::mapInput(Node::class, ['element' => ['value' => 1 ] ], [], new NullLogger());
expect($input)->toBe(['element' => ['value' => 1]]);
});
readonly class MappedElement
{
public function __construct(#[FromInput('name')] public int $value) {}
}
readonly class MappedNode
{
public function __construct(public MappedElement $element) {}
}
test('basic nested input map', function () {
$input = DataObjectFactory::mapInput(MappedNode::class, ['element' => ['name' => 1 ] ], [], new NullLogger());
expect($input)->toBe(['element' => ['value' => 1]]);
});
readonly class MappedCollectionItem
{
public function __construct(#[FromInput('id_item')] public int $idItem) {}
}
readonly class MappedCollectionRoot
{
/**
* @param Collection<int, MappedCollectionItem> $items
*/
public function __construct(public string $text, #[FromInput('data')] public Collection $items) {}
}
test('using from input nested', function () {
$mapped = DataObjectFactory::mapInput(MappedCollectionRoot::class, [
'text' => 'abc',
'data' => [
[ 'id_item' => 1 ],
[ 'id_item' => 2 ],
[ 'id_item' => 4 ],
[ 'id_item' => 8 ],
],
], [], new NullLogger());
expect($mapped)->toBe([
'text' => 'abc',
'items' => [
[ 'idItem' => 1 ],
[ 'idItem' => 2 ],
[ 'idItem' => 4 ],
[ 'idItem' => 8 ],
],
]);
});
readonly class CollectionRoot
{
/**
* @param Collection<int, MappedCollectionItem> $items
*/
public function __construct(public string $text, public Collection $items) {}
}
test('using from input', function () {
$mapped = DataObjectFactory::mapInput(MappedCollectionRoot::class, [
'text' => 'abc',
'items' => [
[ 'id_item' => 1 ],
[ 'id_item' => 2 ],
[ 'id_item' => 4 ],
[ 'id_item' => 8 ],
],
], [], new NullLogger());
expect($mapped)->toBe([
'text' => 'abc',
'items' => [
[ 'idItem' => 1 ],
[ 'idItem' => 2 ],
[ 'idItem' => 4 ],
[ 'idItem' => 8 ],
],
]);
});
readonly class AnnotatedArrayItem
{
public function __construct(#[FromInput('name')] public float $value) {}
}
readonly class AnnotatedArray
{
/**
* @param array<int,AnnotatedArrayItem> $items
*/
public function __construct(public array $items) {}
}
test('annotated array', function () {
$mapped = DataObjectFactory::mapInput(
AnnotatedArray::class,
['items' => [['name' => 1], ['name' => 2]]],
[],
new NullLogger(),
);
expect($mapped)->toBe(['items' => [['value' => 1], ['value' => 2]]]);
});
test('route parameter priority over from input', function () {
$dto = new class (123) {
public function __construct(
#[FromRouteParameter('user_id')]
#[FromInput('user_id')]
public int $id,
) {}
};
$result = DataObjectFactory::mapInput(
$dto::class,
['user_id' => 456],
['user_id' => 123],
new \Psr\Log\NullLogger(),
);
expect($result['id'])->toBe(123);
});
test('multiple route parameters', function () {
$dto = new class (45, 89, 'A') {
public function __construct(
#[FromRouteParameter('course_id')]
public int $courseId,
#[FromRouteParameter('student_id')]
public int $studentId,
#[FromInput('grade')]
public string $grade,
) {}
};
$result = DataObjectFactory::mapInput(
$dto::class,
['grade' => 'A'],
['course_id' => 45, 'student_id' => 89],
new \Psr\Log\NullLogger(),
);
expect($result['courseId'])->toBe(45)
->and($result['studentId'])->toBe(89)
->and($result['grade'])->toBe('A');
});
test('route parameter with nested object', function () {
$addressDto = new class ('Main St') {
public function __construct(public string $street) {}
};
$dto = new class (123, $addressDto) {
public function __construct(
#[FromRouteParameter('user_id')]
public int $userId,
public $address,
) {}
};
$result = DataObjectFactory::mapInput(
$dto::class,
['user_id' => 456, 'address' => ['street' => 'Main St']],
['user_id' => 123],
new \Psr\Log\NullLogger(),
);
expect($result['userId'])->toBe(123)
->and($result['address']['street'])->toBe('Main St');
});
readonly class SimpleWithDefaults
{
public function __construct(
public string $name,
public int $age,
public string $city = 'Unknown',
) {}
/**
* @return array<string,mixed>
*/
public static function defaults(): array
{
return [
'city' => 'New York',
'age' => 25,
];
}
}
test('defaults with fromArray - basic usage', function () {
$object = DataObjectFactory::fromArray(
SimpleWithDefaults::class,
['name' => 'John'],
[],
);
expect($object->name)->toBe('John')
->and($object->age)->toBe(25)
->and($object->city)->toBe('New York');
});
readonly class NestedWithDefaults
{
public function __construct(
public string $title,
public SimpleWithDefaults $user,
) {}
/**
* @return array<string,array<string,mixed>>
*/
public static function defaults(): array
{
return [
'user' => [
'name' => 'Default User',
'age' => 30,
],
];
}
}
test('defaults with nested objects', function () {
$object = DataObjectFactory::fromArray(
NestedWithDefaults::class,
['title' => 'Admin Dashboard'],
[],
);
expect($object->title)->toBe('Admin Dashboard')
->and($object->user->name)->toBe('Default User')
->and($object->user->age)->toBe(30)
->and($object->user->city)->toBe('Unknown');
});
test('defaults merged with input - input overrides defaults', function () {
$object = DataObjectFactory::fromArray(
SimpleWithDefaults::class,
['name' => 'Alice', 'age' => 40, 'city' => 'Los Angeles'],
[],
);
expect($object->name)->toBe('Alice')
->and($object->age)->toBe(40)
->and($object->city)->toBe('Los Angeles');
});
test('defaults with simple nested structures', function () {
$object = DataObjectFactory::fromArray(
NestedWithDefaults::class,
['title' => 'Simple Project'],
[],
);
expect($object->title)->toBe('Simple Project')
->and($object->user->name)->toBe('Default User')
->and($object->user->age)->toBe(30);
});
readonly class ArrayWithDefaults
{
/**
* @param array<string,mixed> $settings
*/
public function __construct(
public string $projectName,
public array $settings,
) {}
/**
* @return array<string,array<string,mixed>>
*/
public static function defaults(): array
{
return [
'settings' => [
'theme' => 'dark',
'notifications' => true,
'language' => 'en',
],
];
}
}
test('defaults with array structures', function () {
$object = DataObjectFactory::fromArray(
ArrayWithDefaults::class,
['projectName' => 'New Project'],
[],
);
expect($object->projectName)->toBe('New Project')
->and($object->settings)->toBe([
'theme' => 'dark',
'notifications' => true,
'language' => 'en',
]);
});
test('defaults partially overridden by input', function () {
$object = DataObjectFactory::fromArray(
ArrayWithDefaults::class,
[
'projectName' => 'Custom Project',
'settings' => [
'theme' => 'light',
'language' => 'fr',
],
],
[],
);
expect($object->projectName)->toBe('Custom Project')
->and($object->settings)->toBe([
'theme' => 'light',
'notifications' => true, // From defaults
'language' => 'fr',
]);
});
readonly class WithInputMappingAndDefaults
{
public function __construct(
#[FromInput('full_name')]
public string $name,
#[FromInput('user_age')]
public int $age,
public string $role = 'user',
) {}
/**
* @return array<string,mixed>
*/
public static function defaults(): array
{
return [
'name' => 'Default Name',
'role' => 'admin',
'age' => 18,
];
}
}
test('defaults work with FromInput attribute mapping', function () {
$object = DataObjectFactory::fromArray(
WithInputMappingAndDefaults::class,
['full_name' => 'John Doe'],
[],
);
expect($object->name)->toBe('John Doe') // From input mapping
->and($object->age)->toBe(18) // From defaults
->and($object->role)->toBe('admin'); // From defaults
});
test('array_merge_recursive behavior with nested arrays', function () {
$object = DataObjectFactory::fromArray(
ArrayWithDefaults::class,
[
'projectName' => 'Test Project',
'settings' => [
'new_setting' => 'custom_value',
],
],
[],
);
expect($object->settings)->toBe([
'theme' => 'dark', // From defaults
'notifications' => true, // From defaults
'language' => 'en', // From defaults
'new_setting' => 'custom_value', // From input
]);
});

View file

@ -1,212 +0,0 @@
<?php
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\OptionalData;
use Tests\Classes\OptionalNullableData;
use Tests\Classes\PrimitiveData;
use Tests\Classes\RecursiveDataObject;
use Tests\Classes\WithMapperObject;
describe('primitive data test', function () {
it('creates required rules', function () {
$rules = (new RuleFactory(new Log()))->make(PrimitiveData::class);
expect($rules)->toMatchArray([
'string' => ['required'],
'int' => ['required', 'numeric'],
'float' => ['required', 'numeric'],
'bool' => ['required', 'boolean'],
]);
});
it('creates object with all required properties', function () {
$object = PrimitiveData::fromArray([
'string' => 'abc',
'int' => 0,
'float' => 3.14,
'bool' => true,
]);
expect($object)->toBeInstanceOf(PrimitiveData::class);
expect($object->string)->toBe('abc');
expect($object->int)->toBe(0);
expect($object->float)->toEqualWithDelta(3.14, 0.0001);
expect($object->bool)->toBeTrue();
});
});
describe('optional data', function () {
it('creates optional rules', function () {
$rules = (new RuleFactory(new Log()))->make(OptionalData::class);
expect($rules)->toMatchArray([
'string' => ['sometimes'],
'int' => ['sometimes', 'numeric'],
'float' => ['sometimes', 'numeric'],
'bool' => ['sometimes', 'boolean'],
]);
});
it('creates object with default values', function () {
$object = OptionalData::fromArray([]);
expect($object)->toBeInstanceOf(OptionalData::class);
expect($object->string)->toBe('xyz');
expect($object->int)->toBe(3);
expect($object->float)->toEqualWithDelta(0.777, 0.0001);
expect($object->bool)->toBeFalse();
});
});
describe('nullable data', function () {
it('creates nullable rules', function () {
$rules = (new RuleFactory(new Log()))->make(OptionalNullableData::class);
expect($rules)->toMatchArray([
'string' => ['required'],
'int' => ['nullable', 'numeric'],
'float' => ['sometimes', 'numeric'],
'bool' => ['sometimes', 'boolean'],
]);
});
it('accepts explicit null', function () {
$object = OptionalNullableData::fromArray([
'string' => 'ijk',
'int' => null,
]);
expect($object)->toBeInstanceOf(OptionalNullableData::class);
expect($object->string)->toBe('ijk');
expect($object->int)->toBeNull();
});
it('accepts implicit null', function () {
$object = OptionalNullableData::fromArray(['string' => 'dfg']);
expect($object)->toBeInstanceOf(OptionalNullableData::class);
expect($object->string)->toBe('dfg');
expect($object->int)->toBeNull();
});
});
describe('reference other DataObject', function () {
it('creates recursive rules', function () {
$rules = (new RuleFactory(new Log()))->make(RecursiveDataObject::class);
expect($rules)->toMatchArray([
'string' => ['required'],
'extra.string' => ['required'],
'extra.int' => ['required', 'numeric'],
'extra.float' => ['required', 'numeric'],
'extra.bool' => ['required', 'boolean'],
]);
});
});
describe('primitive array', function () {
it('creates array rules', function () {
$rules = (new RuleFactory(new Log()))->make(ArrayDataObject::class);
expect($rules)->toMatchArray([
'values' => ['required', 'array'],
'values.*' => ['required', 'numeric'],
]);
});
});
describe('object array', function () {
it('creates array rules', function () {
$rules = (new RuleFactory(new Log()))->make(CollectionDataObject::class);
expect($rules)->toMatchArray([
'values' => ['required', 'array'],
'values.*' => ['required'],
'values.*.string' => ['required'],
'values.*.int' => ['nullable', 'numeric'],
'values.*.float' => ['sometimes', 'numeric'],
'values.*.bool' => ['sometimes', 'boolean'],
]);
});
});
describe('can map input names', function () {
it('creates rules with property names', function () {
$rules = (new RuleFactory(new Log()))->make(FromInputObject::class);
expect($rules)->toMatchArray([
'text' => ['required' ],
'standard' => ['required', 'numeric'],
]);
});
it('maps input name', function () {
$object = FromInputObject::fromArray([
'other_name' => 'xyz',
'standard' => 1,
]);
expect($object->text)->toBe('xyz');
expect($object->standard)->toBe(1);
});
it('prioritizes the mapped input', function () {
$object = FromInputObject::fromArray([
'other_name' => 'xyz',
'text' => 'abc',
'standard' => 1,
]);
expect($object->text)->toBe('xyz');
expect($object->standard)->toBe(1);
});
});
describe('with mapper object', function () {
it('uses mapper', function () {
$object = WithMapperObject::fromArray([
'period' => [
'start' => '1980-01-01',
'end' => '1990-01-01',
],
'standard' => 1,
]);
expect($object->period->startsAt('1980-01-01'))->toBeTrue();
expect($object->period->endsAt('1990-01-01'))->toBeTrue();
});
it('uses mapper as validator', function () {
$object = WithMapperObject::fromArray([
'period' => [
'end' => '1990-01-01',
],
'standard' => 1,
]);
})->throws(ValidationException::class);
});
test('failed validation throws ValidationException', function () {
$object = PrimitiveData::fromArray([
'int' => 0,
'float' => 3.14,
'bool' => true,
]);
})->throws(ValidationException::class);
test('creates collection', function () {
$object = CollectionDataObject::fromArray([
'values' => [
[
'string' => 'x',
'int' => 1,
'float' => 3.3,
],
[
'string' => 'y',
'int' => null,
],
],
]);
expect($object->values->count())->toBe(2);
expect($object->values[0]->string)->toBe('x');
expect($object->values[1]->int)->toBeNull();
});

View file

@ -1,108 +0,0 @@
<?php
namespace Tests\FailedValidation;
use Illuminate\Validation\ValidationException;
use Tests\Classes\FailsReturnsDefault;
use Tests\Classes\FailsReturnsNull;
use Tests\Classes\FailsWithHttpResponse;
use Tests\Classes\PrimitiveData;
describe('fails method behavior', function () {
it('throws ValidationException when class does not implement fails()', function () {
expect(function () {
PrimitiveData::fromArray([
'int' => 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']);
});
});

107
tests/Http/RequestTest.php Normal file
View file

@ -0,0 +1,107 @@
<?php
namespace Tests\Http;
use Icefox\Data\IData;
use Illuminate\Http\Exceptions\HttpResponseException;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Validator as ValidatorFacade;
use Illuminate\Validation\Validator;
readonly class Basic implements IData
{
public string $reply;
public function __construct(string $message)
{
$this->reply = $message == 'ping' ? 'pong' : 'unknown';
}
}
test('injects data object', function () {
Route::post('/', fn(Basic $object) => ['reply' => $object->reply]);
/** @var \Tests\TestCase $this */
$this->postJson('/', ['message' => 'ping'])->assertJson(['reply' => 'pong']);
});
test('fails on validation error', function () {
Route::post('/', fn(Basic $object) => ['reply' => $object->reply]);
/** @var \Tests\TestCase $this */
$resp = $this->postJson('/', [])
->assertStatus(422)
->assertJson([
'message' => 'The message field is required.',
'errors' => ['message' => ['The message field is required.']],
]);
});
readonly class WithCustomValidator implements IData
{
public string $reply;
public function __construct(string $message)
{
$this->reply = $message == 'ping' ? 'pong' : 'unknown';
}
/**
* @param array<string,mixed> $data
* @param array<string,mixed> $rules
*/
public static function withValidator(array $data, array $rules): Validator
{
return ValidatorFacade::make($data, $rules, $messages = [
'message.required' => 'the known message is pong',
]);
}
}
test('replies with custom validator', function () {
Route::post('/', fn(WithCustomValidator $object) => []);
/** @var \Tests\TestCase $this */
$this->postJson('/', [])
->assertStatus(422)
->assertJson([
'message' => 'the known message is pong',
'errors' => ['message' => ['the known message is pong']],
]);
});
readonly class WithCustomFailure implements IData
{
public function __construct(public bool $flag) {}
public static function fails(Validator $validator): void
{
throw new HttpResponseException(
response(['result' => 'invalid, but that is ok' ], 202),
);
}
}
test('uses custom response', function () {
Route::post('/', fn(WithCustomFailure $object) => response('', 204));
/** @var \Tests\TestCase $this */
$this->postJson('/', [])
->assertStatus(202)
->assertJson(['result' => 'invalid, but that is ok']);
});
readonly class WithDefaultObjectOnFailure implements IData
{
public function __construct(public bool $flag) {}
public static function fails(): self
{
return new self(false);
}
}
test('uses default object on failure', function () {
Route::post('/', fn(WithDefaultObjectOnFailure $object) => response(['flag' => $object->flag], 200));
/** @var \Tests\TestCase $this */
$this->postJson('/', [])
->assertStatus(200)
->assertJson(['flag' => false]);
});

View file

@ -1,36 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Logging;
use Psr\Log\AbstractLogger;
class CustomLogger extends AbstractLogger
{
public array $logs = [];
public function log($level, string|\Stringable $message, array $context = []): void
{
$this->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 = [];
}
}

View file

@ -1,281 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Logging;
use Icefox\DTO\Log;
use Psr\Log\LogLevel;
use Psr\Log\NullLogger;
use Tests\Classes\PrimitiveData;
use Tests\TestCase;
describe('logger resolution', function () {
afterEach(function () {
config()->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');
});
});

View file

@ -4,97 +4,208 @@ declare(strict_types=1);
namespace Tests\Rules; namespace Tests\Rules;
use Icefox\DTO\Log; use Icefox\Data\Attributes\Flat;
use Icefox\DTO\ReflectionHelper; use Icefox\Data\Attributes\Overwrite;
use Icefox\DTO\Support\RuleFactory; use Icefox\Data\Factories\RuleFactory;
use Illuminate\Validation\ValidationException; use Illuminate\Support\Collection;
use Tests\Rules\WithEmptyOverwriteRules;
use Tests\Rules\WithMergedRules;
use Tests\Rules\WithOverwriteRules;
describe('rules array shape', function () { readonly class BasicPrimitives
it('returns inferred rules shape from RuleFactory::infer (inferred only)', function () { {
$parameters = ReflectionHelper::getParametersMeta(WithMergedRules::class); public function __construct(
$rules = RuleFactory::infer($parameters, ''); public string $text,
public int $number,
public bool $flag,
public ?array $items,
public float $floating = 0.0,
) {}
}
expect($rules)->toBe([ test('required rules', function () {
expect(RuleFactory::instance()->make(BasicPrimitives::class))->toBe([
'text' => ['required'],
'number' => ['required', 'numeric'],
'flag' => ['required', 'boolean'],
'items' => ['nullable', 'array'],
'floating' => ['sometimes', 'numeric'],
]);
});
readonly class AnnotatedArray
{
/**
* @param array<int,float> $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<?float> $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'], 'value' => ['required', 'numeric'],
'leaf' => ['required'],
'leaf.name' => ['required'],
]); ]);
}); });
it('returns inferred rules shape regardless of OverwriteRules attribute', function () {
$parameters = ReflectionHelper::getParametersMeta(WithOverwriteRules::class);
$rules = RuleFactory::infer($parameters, '');
expect($rules)->toBe([ readonly class AnnotatedArrayItem
{
public function __construct(public int $value) {}
}
readonly class AnnotatedArrayObject
{
/**
* @param ?array<AnnotatedArrayItem> $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'], 'value' => ['required', 'numeric'],
'id' => ['required' ],
'leaf' => ['required'],
'leaf.description' => ['required'],
'flag' => ['nullable', 'boolean'],
'level' => ['sometimes', 'numeric'],
]); ]);
});
}); });
describe('getRules method', function () { readonly class AnnotatedCollectionItem
it('returns merged rules from DataObject::getRules()', function () { {
$rules = (new RuleFactory(new Log()))->make(WithMergedRules::class); public function __construct(public int $value) {}
}
expect($rules)->toBe([ readonly class AnnotatedCollection
'value' => ['required', 'numeric', 'max:20'], {
/**
* @param Collection<AnnotatedCollectionItem> $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'],
]); ]);
});
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 () { readonly class MergedRules
$object = WithMergedRules::fromArray([ {
'value' => 10, public function __construct(public int $value, public string $text) {}
/**
* @return array<string,array<int,string>>
*/
public static function rules(): array
{
// only customized fields need to be provided
return [
'value' => ['min:20'],
];
}
}
test('merging rules', function () {
expect(RuleFactory::instance()->make(MergedRules::class))->toBe([
'value' => ['required', 'numeric', 'min:20'],
'text' => ['required'],
]); ]);
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 () { readonly class OverwriteRules
it('uses only custom rules when OverwriteRules attribute is present', function () { {
$object = WithOverwriteRules::fromArray([ // union types are not supported, generated rules are undefined
'value' => 10, public function __construct(public int|bool $value, public string $text) {}
]);
expect($object->value)->toBe(10); /**
}); * @return array<string,array<int,mixed>>
*/
#[Overwrite]
public static function rules(): array
{
// when overwriting, all fields must be provided with all rules, disables rules inference.
return [
'value' => ['required', function ($attribute, $value, $fail) {
if (!is_int($value) && !is_bool($value)) {
$fail("$attribute must be an integer or an array.");
}
}],
'text' => ['required'],
];
}
}
it('fails validation when custom rule is violated', function () { test('overwriting rules', function () {
expect(fn() => WithOverwriteRules::fromArray([ expect(RuleFactory::instance()->make(OverwriteRules::class))->toHaveKeys(['value', 'text']);
'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']);
});
}); });

View file

@ -1,23 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Rules;
use Icefox\DTO\Attributes\OverwriteRules;
use Icefox\DTO\DataObject;
readonly class WithEmptyOverwriteRules
{
use DataObject;
public function __construct(
public int $value,
) {}
#[OverwriteRules]
public static function rules(): array
{
return [];
}
}

View file

@ -1,23 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Rules;
use Icefox\DTO\DataObject;
readonly class WithMergedRules
{
use DataObject;
public function __construct(
public int $value,
) {}
public static function rules(): array
{
return [
'value' => ['max:20'],
];
}
}

View file

@ -1,25 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Rules;
use Icefox\DTO\Attributes\OverwriteRules;
use Icefox\DTO\DataObject;
readonly class WithOverwriteRules
{
use DataObject;
public function __construct(
public int $value,
) {}
#[OverwriteRules]
public static function rules(): array
{
return [
'value' => ['numeric', 'max:20'],
];
}
}

View file

@ -1,161 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests;
use Icefox\DTO\Attributes\Flat;
use Icefox\DTO\Support\RuleFactory;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Validator;
readonly class BasicPrimitives
{
public function __construct(
public string $text,
public int $number,
public bool $flag,
public ?array $items,
public float $floating = 0.0,
) {}
}
test('required rules', function () {
expect(RuleFactory::instance()->make(BasicPrimitives::class))->toBe([
'text' => ['required'],
'number' => ['required', 'numeric'],
'flag' => ['required', 'boolean'],
'items' => ['nullable', 'array'],
'floating' => ['sometimes', 'numeric'],
]);
});
readonly class AnnotatedArray
{
/**
* @param array<int,float> $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<?float> $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<AnnotatedArrayItem> $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<AnnotatedCollectionItem> $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'],
]);
});

273
tests/Values/ValuesTest.php Normal file
View file

@ -0,0 +1,273 @@
<?php
declare(strict_types=1);
namespace Tests\Values;
use Carbon\CarbonPeriod;
use Icefox\Data\Attributes\CastWith;
use Icefox\Data\Factories\ValueFactory;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
readonly class BasicPrimitives
{
public function __construct(
public string $text,
public int $number,
public bool $flag,
public ?array $items,
public float $floating = 4.7,
) {}
}
test('basic creation works', function () {
$object = ValueFactory::make(BasicPrimitives::class, [
'text' => 'abc',
'number' => 42,
'flag' => true,
'items' => ['a', 2, false],
'floating' => 32.6,
]);
expect($object->text)->toBe('abc');
expect($object->number)->toBe(42);
expect($object->flag)->toBe(true);
expect($object->items)->toBe(['a', 2, false]);
expect($object->floating)->toBe(32.6);
});
test('uses default values', function () {
$object = ValueFactory::make(BasicPrimitives::class, [
'text' => 'abc',
'number' => 42,
'flag' => true,
'items' => ['a', 2, false],
]);
expect($object->text)->toBe('abc');
expect($object->number)->toBe(42);
expect($object->flag)->toBe(true);
expect($object->items)->toBe(['a', 2, false]);
expect($object->floating)->toBe(4.7);
});
test('uses default when null and not nullable', function () {
$object = ValueFactory::make(BasicPrimitives::class, [
'text' => 'abc',
'number' => 42,
'flag' => true,
'items' => ['a', 2, false],
'floating' => null,
]);
expect($object->text)->toBe('abc');
expect($object->number)->toBe(42);
expect($object->flag)->toBe(true);
expect($object->items)->toBe(['a', 2, false]);
expect($object->floating)->toBe(4.7);
});
test('accepts null when nullable', function () {
$object = ValueFactory::make(BasicPrimitives::class, [
'text' => 'abc',
'number' => 42,
'flag' => true,
'items' => null,
]);
expect($object->text)->toBe('abc');
expect($object->number)->toBe(42);
expect($object->flag)->toBe(true);
expect($object->items)->toBe(null);
expect($object->floating)->toBe(4.7);
});
test('accepts missing as null when nullable', function () {
$object = ValueFactory::make(BasicPrimitives::class, [
'text' => 'abc',
'number' => 42,
'flag' => true,
]);
expect($object->text)->toBe('abc');
expect($object->number)->toBe(42);
expect($object->flag)->toBe(true);
expect($object->items)->toBe(null);
expect($object->floating)->toBe(4.7);
});
readonly class NestedLeaf
{
public function __construct(public bool $flag) {}
}
readonly class RootWithNestedLeaf
{
public function __construct(public int $value, public NestedLeaf $leaf) {}
}
test('creates nested object', function () {
$root = ValueFactory::make(RootWithNestedLeaf::class, [
'value' => 42,
'leaf' => [
'flag' => true,
],
]);
expect($root->value)->toBe(42);
expect($root->leaf->flag)->toBe(true);
});
readonly class CollectionItem
{
public function __construct(public int $value) {}
}
readonly class CollectionRoot
{
/**
* @param Collection<int, CollectionItem> $items
*/
public function __construct(public string $text, public Collection $items) {}
}
test('creates collection object', function () {
$root = ValueFactory::make(CollectionRoot::class, [
'text' => 'abc',
'items' => [
[ 'value' => 1 ],
[ 'value' => 2 ],
[ 'value' => 4 ],
[ 'value' => 8 ],
],
]);
expect($root->text)->toBe('abc');
expect($root->items)->toBeInstanceOf(Collection::class);
expect($root->items->count())->toBe(4);
expect($root->items[0]->value)->toBe(1);
expect($root->items[1]->value)->toBe(2);
expect($root->items[2]->value)->toBe(4);
expect($root->items[3]->value)->toBe(8);
});
readonly class DoubleCast
{
public static function cast(int $data): int
{
return $data * 2;
}
}
readonly class WithExplicitCast
{
public function __construct(
#[CastWith(DoubleCast::class)]
public int $value,
) {}
}
readonly class WithNestedCast
{
/**
* @param array<int,WithExplicitCast> $items
*/
public function __construct(public array $items) {}
}
test('with explicit cast', function () {
$object = ValueFactory::make(WithExplicitCast::class, ['value' => 32]);
expect($object->value)->toBe(64);
});
test('with nested cast', function () {
$object = ValueFactory::make(WithNestedCast::class, ['items' => [ ['value' => 2], ['value' => 3], ['value' => 5]]]);
expect($object->items[0]->value)->toBe(4);
expect($object->items[1]->value)->toBe(6);
expect($object->items[2]->value)->toBe(10);
});
readonly class CarbonPeriodCast
{
/**
* @param array<int,mixed> $data
*/
public static function cast(array $data): CarbonPeriod
{
return new CarbonPeriod(Carbon::parse($data['start']), Carbon::parse($data['end']));
}
}
readonly class WithObjectCast
{
public function __construct(
#[CastWith(CarbonPeriodCast::class)]
public CarbonPeriod $period,
) {}
}
test('with object cast', function () {
$object = ValueFactory::make(WithObjectCast::class, ['period' => ['start' => '1980-10-01', 'end' => '1990-06-01']]);
expect($object->period)->toBeInstanceOf(CarbonPeriod::class);
expect($object->period->start->format('Y-m-d'))->toBe('1980-10-01');
expect($object->period->end->format('Y-m-d'))->toBe('1990-06-01');
});
enum Status: string
{
case ACTIVE = 'active';
case INACTIVE = 'inactive';
case PENDING = 'pending';
}
readonly class TaskDTOWithEnum
{
public function __construct(
public string $title,
public Status $status,
) {}
}
readonly class TaskDTOWithNullableEnum
{
public function __construct(
public string $title,
public ?Status $status,
) {}
}
test('backed enum properly cast from validated data', function () {
$object = ValueFactory::make(TaskDTOWithEnum::class, [
'title' => 'Task 1',
'status' => 'active',
]);
expect($object->title)->toBe('Task 1')
->and($object->status)->toBeInstanceOf(Status::class)
->and($object->status)->toBe(Status::ACTIVE);
});
test('nullable backed enum with null', function () {
$object = ValueFactory::make(TaskDTOWithNullableEnum::class, [
'title' => 'Task 1',
'status' => null,
]);
expect($object->title)->toBe('Task 1')
->and($object->status)->toBeNull();
});
test('nullable backed enum with valid value', function () {
$object = ValueFactory::make(TaskDTOWithNullableEnum::class, [
'title' => 'Task 1',
'status' => 'pending',
]);
expect($object->title)->toBe('Task 1')
->and($object->status)->toBeInstanceOf(Status::class)
->and($object->status)->toBe(Status::PENDING);
});

View file

@ -1,8 +1,13 @@
<?php <?php
use Icefox\Data\CustomHandlers;
use Illuminate\Support\Collection;
use Psr\Log\LogLevel; use Psr\Log\LogLevel;
return [ return [
'rules' => [
Collection::class => CustomHandlers::class . "::CollectionRules",
],
'logging' => [ 'logging' => [
'channel' => 'dto', 'channel' => 'dto',
'context' => [ 'context' => [
@ -12,4 +17,7 @@ return [
'internals' => LogLevel::DEBUG, 'internals' => LogLevel::DEBUG,
], ],
], ],
'listTypes' => [
Collection::class,
],
]; ];

View file

@ -1,17 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
>
<testsuites>
<testsuite name="root">
<directory>tests</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>app</directory>
</include>
</source>
</phpunit>