Compare commits
No commits in common. "a5b80681c96109589eecb1a1327be2e18e1ed9a8" and "367858c97cb9b911027597a81a4d2401a08fb4fb" have entirely different histories.
a5b80681c9
...
367858c97c
51 changed files with 1635 additions and 1248 deletions
|
|
@ -1,7 +1,6 @@
|
||||||
{
|
{
|
||||||
"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",
|
||||||
|
|
@ -19,12 +18,15 @@
|
||||||
"license": "GPL-2.0-only",
|
"license": "GPL-2.0-only",
|
||||||
"autoload": {
|
"autoload": {
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
"Icefox\\Data\\": "src/"
|
"Icefox\\DTO\\": "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": [
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,8 @@
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Icefox\Data\Attributes;
|
namespace Icefox\DTO\Attributes;
|
||||||
|
|
||||||
use Attribute;
|
use Attribute;
|
||||||
|
|
||||||
#[Attribute(Attribute::TARGET_PARAMETER)]
|
#[Attribute(Attribute::TARGET_PARAMETER)]
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Icefox\Data\Attributes;
|
namespace Icefox\DTO\Attributes;
|
||||||
|
|
||||||
use Attribute;
|
use Attribute;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Icefox\Data\Attributes;
|
namespace Icefox\DTO\Attributes;
|
||||||
|
|
||||||
use Attribute;
|
use Attribute;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Icefox\Data\Attributes;
|
namespace Icefox\DTO\Attributes;
|
||||||
|
|
||||||
use Attribute;
|
use Attribute;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,11 @@
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Icefox\Data\Attributes;
|
namespace Icefox\DTO\Attributes;
|
||||||
|
|
||||||
use Attribute;
|
use Attribute;
|
||||||
|
|
||||||
#[Attribute(Attribute::TARGET_METHOD)]
|
#[Attribute(Attribute::TARGET_METHOD)]
|
||||||
class Overwrite {}
|
class OverwriteRules
|
||||||
|
{
|
||||||
|
}
|
||||||
63
src/Config.php
Normal file
63
src/Config.php
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
<?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, '.*'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,14 +2,13 @@
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Icefox\Data;
|
namespace Icefox\DTO;
|
||||||
|
|
||||||
use Icefox\Data\Factories\DataObjectFactory;
|
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
trait DataObject
|
trait DataObject
|
||||||
{
|
{
|
||||||
public static function fromRequest(Request $request): ?static
|
public static function fromRequest(Request $request): mixed
|
||||||
{
|
{
|
||||||
return DataObjectFactory::fromRequest(static::class, $request);
|
return DataObjectFactory::fromRequest(static::class, $request);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
81
src/DataObjectFactory.php
Normal file
81
src/DataObjectFactory.php
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
<?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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,16 +1,17 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Icefox\Data;
|
namespace Icefox\DTO\Factories;
|
||||||
|
|
||||||
use Icefox\Data\Factories\RuleFactory;
|
use Icefox\DTO\ParameterMeta;
|
||||||
|
use Icefox\DTO\Support\RuleFactory;
|
||||||
use phpDocumentor\Reflection\PseudoTypes\Generic;
|
use phpDocumentor\Reflection\PseudoTypes\Generic;
|
||||||
|
|
||||||
class CustomHandlers
|
class CollectionFactory
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* @return array<string,string[]>
|
* @return array<string,string[]>
|
||||||
*/
|
*/
|
||||||
public static function CollectionRules(ParameterMeta $parameter, RuleFactory $factory): array
|
public static function rules(ParameterMeta $parameter, RuleFactory $factory): array
|
||||||
{
|
{
|
||||||
if (is_null($parameter->tag)) {
|
if (is_null($parameter->tag)) {
|
||||||
return [];
|
return [];
|
||||||
|
|
@ -1,144 +0,0 @@
|
||||||
<?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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace Icefox\Data;
|
|
||||||
|
|
||||||
interface IData {}
|
|
||||||
41
src/InputFactory.php
Normal file
41
src/InputFactory.php
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Icefox\Data;
|
namespace Icefox\DTO;
|
||||||
|
|
||||||
use ReflectionParameter;
|
use ReflectionParameter;
|
||||||
use phpDocumentor\Reflection\DocBlock\Tags\Param;
|
use phpDocumentor\Reflection\DocBlock\Tags\Param;
|
||||||
|
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
<?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']));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +1,10 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Icefox\Data;
|
namespace Icefox\DTO;
|
||||||
|
|
||||||
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;
|
||||||
|
|
@ -47,34 +44,5 @@ 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;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,13 @@
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Icefox\Data\Factories;
|
namespace Icefox\DTO\Support;
|
||||||
|
|
||||||
use Icefox\Data\Attributes\Flat;
|
use Icefox\DTO\Attributes\Flat;
|
||||||
use Icefox\Data\Attributes\Overwrite;
|
use Icefox\DTO\Attributes\OverwriteRules;
|
||||||
use Icefox\Data\ParameterMeta;
|
use Icefox\DTO\Config;
|
||||||
use Icefox\Data\ReflectionHelper;
|
use Icefox\DTO\ParameterMeta;
|
||||||
use Icefox\Data\Factories\RuleFactory;
|
use Icefox\DTO\ReflectionHelper;
|
||||||
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<int, mixed>>
|
* @return array<string, array<string|Rule>>
|
||||||
*/
|
*/
|
||||||
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,mixed>>
|
* @return array<string,array<int,string|Rule>>
|
||||||
*/
|
*/
|
||||||
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, mixed>>
|
* @return array<string, array<int, string|Rule>>
|
||||||
*/
|
*/
|
||||||
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('dto.rules.' . $name, null)) {
|
if ($globalRules = Config::getRules($name)) {
|
||||||
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, mixed>>
|
* @return array<string,array<int, string>>
|
||||||
*/
|
*/
|
||||||
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(Overwrite::class))) {
|
if ($hasRulesMethod && !empty($classReflection->getMethod('rules')->getAttributes(OverwriteRules::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, mixed>> $first
|
* @param array<string,array<int, string>> $first
|
||||||
* @param array<string,array<int, mixed>> $second
|
* @param array<string,array<int, string>> $second
|
||||||
* @return array<string,array<int, mixed>>
|
* @return array<string,array<int, string>>
|
||||||
*/
|
*/
|
||||||
public function mergeRules(array $first, array $second): array
|
public function mergeRules(array $first, array $second): array
|
||||||
{
|
{
|
||||||
|
|
@ -2,13 +2,17 @@
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Icefox\Data\Factories;
|
namespace Icefox\DTO\Support;
|
||||||
|
|
||||||
use Icefox\Data\Attributes\CastWith;
|
use Icefox\DTO\Attributes\CastWith;
|
||||||
use Icefox\Data\Attributes\Flat;
|
use Icefox\DTO\Attributes\Flat;
|
||||||
use Icefox\Data\ReflectionHelper;
|
use Icefox\DTO\Config;
|
||||||
|
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;
|
||||||
|
|
@ -20,6 +24,21 @@ 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) {
|
||||||
|
|
@ -72,24 +91,17 @@ class ValueFactory
|
||||||
|
|
||||||
public static function resolveDeclaredTypeValue(ReflectionNamedType $parameter, mixed $rawValue): mixed
|
public static function resolveDeclaredTypeValue(ReflectionNamedType $parameter, mixed $rawValue): mixed
|
||||||
{
|
{
|
||||||
$type = $parameter->getName();
|
return match ($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($type, $rawValue),
|
default => self::make($parameter->getName(), $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);
|
||||||
|
|
@ -100,9 +112,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)) {
|
||||||
$arguments[$name] = $parameter->reflection->isDefaultValueAvailable()
|
if ($parameter->reflection->allowsNull()) {
|
||||||
? $parameter->reflection->getDefaultValue()
|
$arguments[$name] = null;
|
||||||
: null;
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -116,27 +128,25 @@ 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()?->__toString();
|
$parameterClass = $type->getFqsen();
|
||||||
}
|
}
|
||||||
|
|
||||||
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 (!is_null($type)) {
|
if ($parameter->tag instanceof Param) {
|
||||||
$arguments[$name] = self::resolveAnnotatedValue($type, $parameterArgs);
|
$arguments[$name] = self::resolveAnnotatedValue($type, $parameterArgs);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$reflectionType = $parameter->reflection->getType();
|
if ($parameter->reflection->getType() instanceof ReflectionNamedType) {
|
||||||
if ($reflectionType instanceof ReflectionNamedType) {
|
$arguments[$name] = self::resolveDeclaredTypeValue($parameter->reflection->getType(), $parameterArgs);
|
||||||
$arguments[$name] = self::resolveDeclaredTypeValue($reflectionType, $parameterArgs);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$arguments[$name] = $parameterArgs;
|
$arguments[$name] = $parameterArgs;
|
||||||
}
|
}
|
||||||
return new $class(...$arguments);
|
return App::makeWith($class, $arguments);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
71
tests/Casters/CasterTest.php
Normal file
71
tests/Casters/CasterTest.php
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
<?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
|
||||||
|
});
|
||||||
|
});
|
||||||
10
tests/Casters/SimpleValue.php
Normal file
10
tests/Casters/SimpleValue.php
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Casters;
|
||||||
|
|
||||||
|
class SimpleValue
|
||||||
|
{
|
||||||
|
public function __construct(public readonly int $value) {}
|
||||||
|
}
|
||||||
20
tests/Casters/SimpleValueCaster.php
Normal file
20
tests/Casters/SimpleValueCaster.php
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
<?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'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
16
tests/Casters/WithGlobalCaster.php
Normal file
16
tests/Casters/WithGlobalCaster.php
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Casters;
|
||||||
|
|
||||||
|
use Icefox\DTO\DataObject;
|
||||||
|
|
||||||
|
readonly class WithGlobalCaster
|
||||||
|
{
|
||||||
|
use DataObject;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public SimpleValue $simple,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
18
tests/Casters/WithSpecificCaster.php
Normal file
18
tests/Casters/WithSpecificCaster.php
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
<?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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
16
tests/Casters/WithoutCaster.php
Normal file
16
tests/Casters/WithoutCaster.php
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Casters;
|
||||||
|
|
||||||
|
use Icefox\DTO\DataObject;
|
||||||
|
|
||||||
|
readonly class WithoutCaster
|
||||||
|
{
|
||||||
|
use DataObject;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public SimpleValue $value,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
16
tests/Classes/ArrayDataObject.php
Normal file
16
tests/Classes/ArrayDataObject.php
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
<?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) {}
|
||||||
|
}
|
||||||
22
tests/Classes/CarbonPeriodMapper.php
Normal file
22
tests/Classes/CarbonPeriodMapper.php
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
<?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'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
17
tests/Classes/CollectionDataObject.php
Normal file
17
tests/Classes/CollectionDataObject.php
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
<?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) {}
|
||||||
|
}
|
||||||
23
tests/Classes/FailsReturnsDefault.php
Normal file
23
tests/Classes/FailsReturnsDefault.php
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
<?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');
|
||||||
|
}
|
||||||
|
}
|
||||||
23
tests/Classes/FailsReturnsNull.php
Normal file
23
tests/Classes/FailsReturnsNull.php
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
26
tests/Classes/FailsWithHttpResponse.php
Normal file
26
tests/Classes/FailsWithHttpResponse.php
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
<?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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
19
tests/Classes/FromInputObject.php
Normal file
19
tests/Classes/FromInputObject.php
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
<?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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
17
tests/Classes/ObjectWithoutMapper.php
Normal file
17
tests/Classes/ObjectWithoutMapper.php
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
<?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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
19
tests/Classes/OptionalData.php
Normal file
19
tests/Classes/OptionalData.php
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
<?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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
19
tests/Classes/OptionalNullableData.php
Normal file
19
tests/Classes/OptionalNullableData.php
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
<?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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
19
tests/Classes/PrimitiveData.php
Normal file
19
tests/Classes/PrimitiveData.php
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
<?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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
17
tests/Classes/RecursiveDataObject.php
Normal file
17
tests/Classes/RecursiveDataObject.php
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
<?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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
19
tests/Classes/WithMapperObject.php
Normal file
19
tests/Classes/WithMapperObject.php
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
<?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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
@ -1,400 +0,0 @@
|
||||||
<?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
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
212
tests/DataObjectTest.php
Normal file
212
tests/DataObjectTest.php
Normal file
|
|
@ -0,0 +1,212 @@
|
||||||
|
<?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();
|
||||||
|
});
|
||||||
108
tests/FailedValidation/FailsMethodTest.php
Normal file
108
tests/FailedValidation/FailsMethodTest.php
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
<?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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,107 +0,0 @@
|
||||||
<?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]);
|
|
||||||
});
|
|
||||||
36
tests/Logging/CustomLogger.php
Normal file
36
tests/Logging/CustomLogger.php
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
<?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 = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
281
tests/Logging/LogTest.php
Normal file
281
tests/Logging/LogTest.php
Normal file
|
|
@ -0,0 +1,281 @@
|
||||||
|
<?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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -4,208 +4,97 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace Tests\Rules;
|
namespace Tests\Rules;
|
||||||
|
|
||||||
use Icefox\Data\Attributes\Flat;
|
use Icefox\DTO\Log;
|
||||||
use Icefox\Data\Attributes\Overwrite;
|
use Icefox\DTO\ReflectionHelper;
|
||||||
use Icefox\Data\Factories\RuleFactory;
|
use Icefox\DTO\Support\RuleFactory;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use Tests\Rules\WithEmptyOverwriteRules;
|
||||||
|
use Tests\Rules\WithMergedRules;
|
||||||
|
use Tests\Rules\WithOverwriteRules;
|
||||||
|
|
||||||
readonly class BasicPrimitives
|
describe('rules array shape', function () {
|
||||||
{
|
it('returns inferred rules shape from RuleFactory::infer (inferred only)', function () {
|
||||||
public function __construct(
|
$parameters = ReflectionHelper::getParametersMeta(WithMergedRules::class);
|
||||||
public string $text,
|
$rules = RuleFactory::infer($parameters, '');
|
||||||
public int $number,
|
|
||||||
public bool $flag,
|
|
||||||
public ?array $items,
|
|
||||||
public float $floating = 0.0,
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
test('required rules', function () {
|
expect($rules)->toBe([
|
||||||
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, '');
|
||||||
|
|
||||||
readonly class AnnotatedArrayItem
|
expect($rules)->toBe([
|
||||||
{
|
|
||||||
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 () {
|
||||||
|
it('returns merged rules from DataObject::getRules()', function () {
|
||||||
|
$rules = (new RuleFactory(new Log()))->make(WithMergedRules::class);
|
||||||
|
|
||||||
|
expect($rules)->toBe([
|
||||||
|
'value' => ['required', 'numeric', 'max:20'],
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly class AnnotatedCollectionItem
|
it('returns only custom rules from DataObject::getRules() with OverwriteRules', function () {
|
||||||
{
|
$rules = (new RuleFactory(new Log()))->make(WithOverwriteRules::class);
|
||||||
public function __construct(public int $value) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
readonly class AnnotatedCollection
|
expect($rules)->toBe([
|
||||||
{
|
'value' => ['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 empty rules from DataObject::getRules() with OverwriteRules and no custom rules', function () {
|
||||||
|
$rules = (new RuleFactory(new Log()))->make(WithEmptyOverwriteRules::class);
|
||||||
|
|
||||||
readonly class MergedRules
|
expect($rules)->toBe([]);
|
||||||
{
|
});
|
||||||
public function __construct(public int $value, public string $text) {}
|
});
|
||||||
|
|
||||||
/**
|
describe('rules merging', function () {
|
||||||
* @return array<string,array<int,string>>
|
it('merges custom rules with inferred rules by default', function () {
|
||||||
*/
|
$object = WithMergedRules::fromArray([
|
||||||
public static function rules(): array
|
'value' => 10,
|
||||||
{
|
|
||||||
// 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);
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly class OverwriteRules
|
it('fails validation when merged rule is violated', function () {
|
||||||
{
|
expect(fn() => WithMergedRules::fromArray([
|
||||||
// union types are not supported, generated rules are undefined
|
'value' => 25,
|
||||||
public function __construct(public int|bool $value, public string $text) {}
|
]))->toThrow(ValidationException::class);
|
||||||
|
});
|
||||||
/**
|
|
||||||
* @return array<string,array<int,mixed>>
|
it('fails validation when required rule is violated (inferred)', function () {
|
||||||
*/
|
expect(fn() => WithMergedRules::fromArray([
|
||||||
#[Overwrite]
|
]))->toThrow(ValidationException::class);
|
||||||
public static function rules(): array
|
});
|
||||||
{
|
});
|
||||||
// when overwriting, all fields must be provided with all rules, disables rules inference.
|
|
||||||
return [
|
describe('rules overwrite', function () {
|
||||||
'value' => ['required', function ($attribute, $value, $fail) {
|
it('uses only custom rules when OverwriteRules attribute is present', function () {
|
||||||
if (!is_int($value) && !is_bool($value)) {
|
$object = WithOverwriteRules::fromArray([
|
||||||
$fail("$attribute must be an integer or an array.");
|
'value' => 10,
|
||||||
}
|
]);
|
||||||
}],
|
|
||||||
'text' => ['required'],
|
expect($object->value)->toBe(10);
|
||||||
];
|
});
|
||||||
}
|
|
||||||
}
|
it('fails validation when custom rule is violated', function () {
|
||||||
|
expect(fn() => WithOverwriteRules::fromArray([
|
||||||
test('overwriting rules', function () {
|
'value' => 25,
|
||||||
expect(RuleFactory::instance()->make(OverwriteRules::class))->toHaveKeys(['value', 'text']);
|
]))->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']);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
23
tests/Rules/WithEmptyOverwriteRules.php
Normal file
23
tests/Rules/WithEmptyOverwriteRules.php
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
<?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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
23
tests/Rules/WithMergedRules.php
Normal file
23
tests/Rules/WithMergedRules.php
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
<?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'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
25
tests/Rules/WithOverwriteRules.php
Normal file
25
tests/Rules/WithOverwriteRules.php
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
<?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'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
161
tests/RulesTest.php
Normal file
161
tests/RulesTest.php
Normal file
|
|
@ -0,0 +1,161 @@
|
||||||
|
<?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'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
@ -1,273 +0,0 @@
|
||||||
<?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);
|
|
||||||
});
|
|
||||||
|
|
@ -1,13 +1,8 @@
|
||||||
<?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' => [
|
||||||
|
|
@ -17,7 +12,4 @@ return [
|
||||||
'internals' => LogLevel::DEBUG,
|
'internals' => LogLevel::DEBUG,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
'listTypes' => [
|
|
||||||
Collection::class,
|
|
||||||
],
|
|
||||||
];
|
];
|
||||||
|
|
|
||||||
17
workbench/phpunit.xml
Normal file
17
workbench/phpunit.xml
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
<?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>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue