Compare commits
10 commits
aa7b062b29
...
367858c97c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
367858c97c | ||
|
|
d83a324eb0 | ||
|
|
74f151df07 | ||
|
|
77d1aebc0a | ||
|
|
75ce822b84 | ||
|
|
f1d46dacb6 | ||
|
|
709201547c | ||
|
|
3a26a2e0c2 | ||
|
|
afb47c1977 | ||
|
|
bef42b3352 |
43 changed files with 1615 additions and 314 deletions
|
|
@ -11,7 +11,9 @@
|
||||||
"pestphp/pest": "^4.4",
|
"pestphp/pest": "^4.4",
|
||||||
"phpstan/phpstan": "^2.1",
|
"phpstan/phpstan": "^2.1",
|
||||||
"friendsofphp/php-cs-fixer": "^3.94",
|
"friendsofphp/php-cs-fixer": "^3.94",
|
||||||
"orchestra/testbench": "^9.16"
|
"orchestra/testbench": "^9.16",
|
||||||
|
"pestphp/pest-plugin-laravel": "^4.0",
|
||||||
|
"laravel/pail": "^1.2"
|
||||||
},
|
},
|
||||||
"license": "GPL-2.0-only",
|
"license": "GPL-2.0-only",
|
||||||
"autoload": {
|
"autoload": {
|
||||||
|
|
@ -21,7 +23,10 @@
|
||||||
},
|
},
|
||||||
"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": [
|
||||||
|
|
@ -34,5 +39,26 @@
|
||||||
"allow-plugins": {
|
"allow-plugins": {
|
||||||
"pestphp/pest-plugin": true
|
"pestphp/pest-plugin": true
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"post-autoload-dump": [
|
||||||
|
"@clear",
|
||||||
|
"@prepare"
|
||||||
|
],
|
||||||
|
"clear": "@php vendor/bin/testbench package:purge-skeleton --ansi",
|
||||||
|
"prepare": "@php vendor/bin/testbench package:discover --ansi",
|
||||||
|
"build": "@php vendor/bin/testbench workbench:build --ansi",
|
||||||
|
"serve": [
|
||||||
|
"Composer\\Config::disableProcessTimeout",
|
||||||
|
"@build",
|
||||||
|
"@php vendor/bin/testbench serve --ansi"
|
||||||
|
],
|
||||||
|
"lint": [
|
||||||
|
"@php vendor/bin/phpstan analyse --verbose --ansi"
|
||||||
|
],
|
||||||
|
"test": [
|
||||||
|
"@clear",
|
||||||
|
"@php vendor/bin/pest"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
76
composer.lock
generated
76
composer.lock
generated
|
|
@ -4,7 +4,7 @@
|
||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "1d9eef5574135e39ab7eaa6beae3fdad",
|
"content-hash": "0ecb4cd71aa6ab475ed1db205f47b632",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "brick/math",
|
"name": "brick/math",
|
||||||
|
|
@ -7992,6 +7992,80 @@
|
||||||
],
|
],
|
||||||
"time": "2025-08-20T13:10:51+00:00"
|
"time": "2025-08-20T13:10:51+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "pestphp/pest-plugin-laravel",
|
||||||
|
"version": "v4.0.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/pestphp/pest-plugin-laravel.git",
|
||||||
|
"reference": "e12a07046b826a40b1c8632fd7b80d6b8d7b628e"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/pestphp/pest-plugin-laravel/zipball/e12a07046b826a40b1c8632fd7b80d6b8d7b628e",
|
||||||
|
"reference": "e12a07046b826a40b1c8632fd7b80d6b8d7b628e",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"laravel/framework": "^11.45.2|^12.25.0",
|
||||||
|
"pestphp/pest": "^4.0.0",
|
||||||
|
"php": "^8.3.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"laravel/dusk": "^8.3.3",
|
||||||
|
"orchestra/testbench": "^9.13.0|^10.5.0",
|
||||||
|
"pestphp/pest-dev-tools": "^4.0.0"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"pest": {
|
||||||
|
"plugins": [
|
||||||
|
"Pest\\Laravel\\Plugin"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"laravel": {
|
||||||
|
"providers": [
|
||||||
|
"Pest\\Laravel\\PestServiceProvider"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"files": [
|
||||||
|
"src/Autoload.php"
|
||||||
|
],
|
||||||
|
"psr-4": {
|
||||||
|
"Pest\\Laravel\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"description": "The Pest Laravel Plugin",
|
||||||
|
"keywords": [
|
||||||
|
"framework",
|
||||||
|
"laravel",
|
||||||
|
"pest",
|
||||||
|
"php",
|
||||||
|
"test",
|
||||||
|
"testing",
|
||||||
|
"unit"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"source": "https://github.com/pestphp/pest-plugin-laravel/tree/v4.0.0"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://www.paypal.com/paypalme/enunomaduro",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/nunomaduro",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2025-08-20T12:46:37+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "pestphp/pest-plugin-mutate",
|
"name": "pestphp/pest-plugin-mutate",
|
||||||
"version": "v4.0.1",
|
"version": "v4.0.1",
|
||||||
|
|
|
||||||
12
flake.nix
12
flake.nix
|
|
@ -16,10 +16,20 @@
|
||||||
system:
|
system:
|
||||||
let
|
let
|
||||||
pkgs = nixpkgs.legacyPackages.${system};
|
pkgs = nixpkgs.legacyPackages.${system};
|
||||||
|
php = (
|
||||||
|
pkgs.php.withExtensions (
|
||||||
|
{ enabled, all }:
|
||||||
|
enabled
|
||||||
|
++ [
|
||||||
|
all.pcntl
|
||||||
|
all.xdebug
|
||||||
|
]
|
||||||
|
)
|
||||||
|
);
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
devShells.default = pkgs.mkShell {
|
devShells.default = pkgs.mkShell {
|
||||||
packages = with pkgs; [
|
packages = [
|
||||||
php
|
php
|
||||||
php.packages.composer
|
php.packages.composer
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
parameters:
|
parameters:
|
||||||
paths:
|
paths:
|
||||||
- src
|
- src
|
||||||
- tests
|
|
||||||
level: 5
|
level: 5
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ namespace Icefox\DTO\Attributes;
|
||||||
use Attribute;
|
use Attribute;
|
||||||
|
|
||||||
#[Attribute(Attribute::TARGET_PARAMETER)]
|
#[Attribute(Attribute::TARGET_PARAMETER)]
|
||||||
class FromMapper
|
class CastWith
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* @param class-string $class
|
* @param class-string $class
|
||||||
12
src/Attributes/Flat.php
Normal file
12
src/Attributes/Flat.php
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Icefox\DTO\Attributes;
|
||||||
|
|
||||||
|
use Attribute;
|
||||||
|
|
||||||
|
#[Attribute(Attribute::TARGET_PARAMETER)]
|
||||||
|
class Flat
|
||||||
|
{
|
||||||
|
}
|
||||||
12
src/Attributes/OverwriteRules.php
Normal file
12
src/Attributes/OverwriteRules.php
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Icefox\DTO\Attributes;
|
||||||
|
|
||||||
|
use Attribute;
|
||||||
|
|
||||||
|
#[Attribute(Attribute::TARGET_METHOD)]
|
||||||
|
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, '.*'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,109 +4,20 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace Icefox\DTO;
|
namespace Icefox\DTO;
|
||||||
|
|
||||||
use Icefox\DTO\Attributes\FromInput;
|
|
||||||
use Icefox\DTO\Attributes\FromMapper;
|
|
||||||
use Icefox\DTO\Attributes\FromRouteParameter;
|
|
||||||
use Icefox\DTO\Support\RuleFactory;
|
|
||||||
use Icefox\DTO\Support\ValueFactory;
|
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\App;
|
|
||||||
use Illuminate\Validation\Rule;
|
|
||||||
use Illuminate\Validation\ValidationException;
|
|
||||||
use Illuminate\Validation\Validator;
|
|
||||||
use ReflectionClass;
|
|
||||||
use phpDocumentor\Reflection\DocBlock\Tags\Param;
|
|
||||||
|
|
||||||
trait DataObject
|
trait DataObject
|
||||||
{
|
{
|
||||||
public static function fromRequest(Request $request): mixed
|
public static function fromRequest(Request $request): mixed
|
||||||
{
|
{
|
||||||
$reflection = new ReflectionClass(static::class);
|
return DataObjectFactory::fromRequest(static::class, $request);
|
||||||
$constructor = $reflection->getConstructor();
|
|
||||||
|
|
||||||
$input = [];
|
|
||||||
foreach ($constructor->getParameters() as $parameter) {
|
|
||||||
$parameterName = $parameter->getName();
|
|
||||||
|
|
||||||
foreach ($parameter->getAttributes(FromRouteParameter::class) as $attr) {
|
|
||||||
$name = $attr->newInstance()->name;
|
|
||||||
if ($value = $request->input($name, null)) {
|
|
||||||
$input[$parameterName] = $value;
|
|
||||||
continue 2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return static::fromArray($input);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string,mixed> $input
|
* @param array<string,mixed> $input
|
||||||
*/
|
*/
|
||||||
public static function fromArray(array $input): static
|
public static function fromArray(array $input): ?static
|
||||||
{
|
{
|
||||||
$parameters = RuleFactory::getParametersMeta(static::class);
|
return DataObjectFactory::fromArray(static::class, $input);
|
||||||
foreach ($parameters as $parameter) {
|
|
||||||
$parameterName = $parameter->reflection->getName();
|
|
||||||
|
|
||||||
foreach ($parameter->reflection->getAttributes(FromInput::class) as $attr) {
|
|
||||||
if ($value = $input[$attr->newInstance()->name] ?? null) {
|
|
||||||
$input[$parameterName] = $value;
|
|
||||||
continue 2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($value = $input[$parameterName] ?? null) {
|
|
||||||
$input[$parameterName] = $value;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($parameter->reflection->isDefaultValueAvailable()) {
|
|
||||||
$input[$parameterName] = $parameter->reflection->getDefaultValue();
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$rules = RuleFactory::buildRules($parameters, '');
|
|
||||||
$validator = static::withValidator($input, $rules);
|
|
||||||
|
|
||||||
if ($validator->fails()) {
|
|
||||||
$exception = new ValidationException($validator);
|
|
||||||
throw $exception;
|
|
||||||
}
|
|
||||||
|
|
||||||
$mappedInput = [];
|
|
||||||
foreach ($parameters as $parameter) {
|
|
||||||
$parameterName = $parameter->reflection->getName();
|
|
||||||
|
|
||||||
if ($mapper = array_first($parameter->reflection->getAttributes(FromMapper::class))) {
|
|
||||||
$value = App::call(
|
|
||||||
[App::make($mapper->newInstance()->class), 'map'],
|
|
||||||
['value' => $validator->getValue($parameterName)],
|
|
||||||
);
|
|
||||||
$mappedInput[$parameterName] = $value;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$mappedInput[$parameterName] = ValueFactory::resolveValue(
|
|
||||||
$validator->getValue($parameterName),
|
|
||||||
$parameter->tag instanceof Param ? $parameter->tag->getType() : null,
|
|
||||||
$parameter->reflection,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return App::make(static::class, $mappedInput);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function rules(): array
|
|
||||||
{
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string,mixed> $data
|
|
||||||
* @param array<string,array<int, string|Rule>> $rules
|
|
||||||
*/
|
|
||||||
public static function withValidator(array $data, array $rules): Validator
|
|
||||||
{
|
|
||||||
return App::makeWith(Validator::class, ['data' => $data, 'rules' => $rules]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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,27 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Icefox\DTO;
|
|
||||||
|
|
||||||
use Icefox\DTO\Support\DataObjectRegistrar;
|
|
||||||
use Illuminate\Support\ServiceProvider;
|
|
||||||
|
|
||||||
class DataObjectServiceProvider extends ServiceProvider
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Register services.
|
|
||||||
*/
|
|
||||||
public function register(): void
|
|
||||||
{
|
|
||||||
$this->mergeConfigFrom(__DIR__ . '/config/dto.php', 'dto');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Bootstrap services.
|
|
||||||
*/
|
|
||||||
public function boot(): void
|
|
||||||
{
|
|
||||||
//
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -2,24 +2,22 @@
|
||||||
|
|
||||||
namespace Icefox\DTO\Factories;
|
namespace Icefox\DTO\Factories;
|
||||||
|
|
||||||
|
use Icefox\DTO\ParameterMeta;
|
||||||
use Icefox\DTO\Support\RuleFactory;
|
use Icefox\DTO\Support\RuleFactory;
|
||||||
use Illuminate\Support\Collection;
|
|
||||||
use Illuminate\Validation\Rule;
|
|
||||||
use ReflectionParameter;
|
|
||||||
use phpDocumentor\Reflection\PseudoTypes\Generic;
|
use phpDocumentor\Reflection\PseudoTypes\Generic;
|
||||||
use phpDocumentor\Reflection\Type;
|
|
||||||
|
|
||||||
class CollectionFactory
|
class CollectionFactory
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* @return array<string,string|Rule[]>
|
* @return array<string,string[]>
|
||||||
*/
|
*/
|
||||||
public static function rules(ReflectionParameter $parameter, ?Type $type): array
|
public static function rules(ParameterMeta $parameter, RuleFactory $factory): array
|
||||||
{
|
{
|
||||||
if (is_null($type)) {
|
if (is_null($parameter->tag)) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$type = $parameter->tag->getType();
|
||||||
if (!$type instanceof Generic) {
|
if (!$type instanceof Generic) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
@ -27,14 +25,14 @@ class CollectionFactory
|
||||||
$subtypes = $type->getTypes();
|
$subtypes = $type->getTypes();
|
||||||
|
|
||||||
if (count($subtypes) == 0) {
|
if (count($subtypes) == 0) {
|
||||||
return [];
|
return ['' => ['array']];
|
||||||
}
|
}
|
||||||
|
|
||||||
$subtype = count($subtypes) == 1 ? $subtypes[0] : $subtypes[1];
|
$subtype = count($subtypes) == 1 ? $subtypes[0] : $subtypes[1];
|
||||||
|
|
||||||
return array_merge(
|
return $factory->mergeRules(
|
||||||
['' => ['array']],
|
['' => ['array']],
|
||||||
RuleFactory::getRulesFromDocBlock($subtype, '.*'),
|
$factory->getRulesFromDocBlock($subtype, '.*'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
48
src/ReflectionHelper.php
Normal file
48
src/ReflectionHelper.php
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Icefox\DTO;
|
||||||
|
|
||||||
|
use ReflectionParameter;
|
||||||
|
use phpDocumentor\Reflection\DocBlock\Tag;
|
||||||
|
use phpDocumentor\Reflection\DocBlock\Tags\Param;
|
||||||
|
use phpDocumentor\Reflection\Types\ContextFactory;
|
||||||
|
use phpDocumentor\Reflection\DocBlockFactory;
|
||||||
|
use ReflectionClass;
|
||||||
|
|
||||||
|
class ReflectionHelper
|
||||||
|
{
|
||||||
|
protected static array $cache = [];
|
||||||
|
/**
|
||||||
|
* @param class-string $class
|
||||||
|
* @return array<ParameterMeta>
|
||||||
|
*/
|
||||||
|
public static function getParametersMeta(string $class): array
|
||||||
|
{
|
||||||
|
if (array_key_exists($class, self::$cache)) {
|
||||||
|
return self::$cache[$class];
|
||||||
|
}
|
||||||
|
|
||||||
|
$reflection = new ReflectionClass($class);
|
||||||
|
$constructor = $reflection->getConstructor();
|
||||||
|
try {
|
||||||
|
$docblockParams = (DocBlockFactory::createInstance())->create(
|
||||||
|
$constructor->getDocComment(),
|
||||||
|
(new ContextFactory())->createFromReflector($constructor),
|
||||||
|
)->getTagsByName('param');
|
||||||
|
} catch (\Exception) {
|
||||||
|
$docblockParams = [];
|
||||||
|
}
|
||||||
|
self::$cache[$class] = array_map(
|
||||||
|
fn(ReflectionParameter $p) => new ParameterMeta(
|
||||||
|
$p,
|
||||||
|
array_find(
|
||||||
|
$docblockParams,
|
||||||
|
fn(Tag $tag) => $tag instanceof Param ? $tag->getVariableName() == $p->getName() : false,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
$constructor->getParameters(),
|
||||||
|
);
|
||||||
|
return self::$cache[$class];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -4,42 +4,41 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace Icefox\DTO\Support;
|
namespace Icefox\DTO\Support;
|
||||||
|
|
||||||
use Icefox\DTO\Attributes\FromMapper;
|
use Icefox\DTO\Attributes\Flat;
|
||||||
|
use Icefox\DTO\Attributes\OverwriteRules;
|
||||||
|
use Icefox\DTO\Config;
|
||||||
use Icefox\DTO\ParameterMeta;
|
use Icefox\DTO\ParameterMeta;
|
||||||
|
use Icefox\DTO\ReflectionHelper;
|
||||||
use Illuminate\Support\Facades\App;
|
use Illuminate\Support\Facades\App;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
use ReflectionClass;
|
use ReflectionClass;
|
||||||
use ReflectionNamedType;
|
use ReflectionNamedType;
|
||||||
use ReflectionParameter;
|
|
||||||
use ReflectionUnionType;
|
use ReflectionUnionType;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use phpDocumentor\Reflection\DocBlockFactory;
|
|
||||||
use phpDocumentor\Reflection\DocBlock\Tag;
|
|
||||||
use phpDocumentor\Reflection\DocBlock\Tags\Param;
|
use phpDocumentor\Reflection\DocBlock\Tags\Param;
|
||||||
use phpDocumentor\Reflection\Type;
|
use phpDocumentor\Reflection\Type;
|
||||||
use phpDocumentor\Reflection\Types\AbstractList;
|
use phpDocumentor\Reflection\Types\AbstractList;
|
||||||
use phpDocumentor\Reflection\Types\Boolean;
|
use phpDocumentor\Reflection\Types\Boolean;
|
||||||
use phpDocumentor\Reflection\Types\ContextFactory;
|
|
||||||
use phpDocumentor\Reflection\Types\Float_;
|
use phpDocumentor\Reflection\Types\Float_;
|
||||||
use phpDocumentor\Reflection\Types\Integer;
|
use phpDocumentor\Reflection\Types\Integer;
|
||||||
use phpDocumentor\Reflection\Types\Nullable;
|
use phpDocumentor\Reflection\Types\Nullable;
|
||||||
use phpDocumentor\Reflection\Types\Object_;
|
use phpDocumentor\Reflection\Types\Object_;
|
||||||
|
|
||||||
class RuleFactory
|
final class RuleFactory
|
||||||
{
|
{
|
||||||
protected static array $cache = [];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, array<string|Rule>>
|
* @return array<string, array<string|Rule>>
|
||||||
*/
|
*/
|
||||||
public static function getRulesFromDocBlock(
|
public function getRulesFromDocBlock(
|
||||||
Type $type,
|
Type $type,
|
||||||
string $prefix,
|
string $prefix,
|
||||||
): array {
|
): array {
|
||||||
$rules = [];
|
$rules = [];
|
||||||
if ($type instanceof Nullable) {
|
if ($type instanceof Nullable) {
|
||||||
$rules[$prefix] = ['nullable'];
|
$rules[$prefix] = ['nullable'];
|
||||||
$rules = array_merge($rules, self::getRulesFromDocBlock($type->getActualType(), $prefix));
|
$type = $type->getActualType();
|
||||||
} else {
|
} else {
|
||||||
$rules[$prefix] = ['required'];
|
$rules[$prefix] = ['required'];
|
||||||
}
|
}
|
||||||
|
|
@ -48,64 +47,31 @@ class RuleFactory
|
||||||
$rules[$prefix][] = 'array';
|
$rules[$prefix][] = 'array';
|
||||||
|
|
||||||
$valueType = $type->getValueType();
|
$valueType = $type->getValueType();
|
||||||
$rules = array_merge($rules, self::getRulesFromDocBlock($valueType, $prefix . '.*'));
|
$rules = $this->mergeRules($rules, $this->getRulesFromDocBlock($valueType, $prefix . '.*'));
|
||||||
}
|
}
|
||||||
if ($type instanceof Boolean) {
|
if ($type instanceof Boolean) {
|
||||||
$rules[$prefix][] = 'boolean';
|
$rules[$prefix][] = 'boolean';
|
||||||
} elseif ($type instanceof Float_ || $type instanceof Integer) {
|
} elseif ($type instanceof Float_ || $type instanceof Integer) {
|
||||||
$rules[$prefix][] = 'numeric';
|
$rules[$prefix][] = 'numeric';
|
||||||
} elseif ($type instanceof Object_) {
|
} elseif ($type instanceof Object_) {
|
||||||
$paramsSub = self::getParametersMeta($type->getFqsen()->__toString());
|
$paramsSub = ReflectionHelper::getParametersMeta($type->getFqsen()->__toString());
|
||||||
$rules = array_merge(
|
$rules = $this->mergeRules($rules, $this->infer($paramsSub, $prefix));
|
||||||
$rules,
|
|
||||||
self::buildRules($paramsSub, $prefix . '.'),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return $rules;
|
return $rules;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param class-string $class
|
|
||||||
* @return array<ParameterMeta>
|
|
||||||
*/
|
|
||||||
public static function getParametersMeta(string $class): array
|
|
||||||
{
|
|
||||||
if (array_key_exists($class, self::$cache)) {
|
|
||||||
return self::$cache[$class];
|
|
||||||
}
|
|
||||||
|
|
||||||
$reflection = new ReflectionClass($class);
|
|
||||||
$constructor = $reflection->getConstructor();
|
|
||||||
try {
|
|
||||||
$docblockParams = (DocBlockFactory::createInstance())->create(
|
|
||||||
$constructor->getDocComment(),
|
|
||||||
(new ContextFactory())->createFromReflector($constructor),
|
|
||||||
)->getTagsByName('param');
|
|
||||||
} catch (\Exception) {
|
|
||||||
$docblockParams = [];
|
|
||||||
}
|
|
||||||
self::$cache[$class] = array_map(
|
|
||||||
fn(ReflectionParameter $p) => new ParameterMeta(
|
|
||||||
$p,
|
|
||||||
array_find(
|
|
||||||
$docblockParams,
|
|
||||||
fn(Tag $tag) => $tag instanceof Param ? $tag->getVariableName() == $p->getName() : false,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
$constructor->getParameters(),
|
|
||||||
);
|
|
||||||
return self::$cache[$class];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<ParameterMeta> $parameters
|
* @param array<ParameterMeta> $parameters
|
||||||
* @return array<string,array<int,string|Rule>>
|
* @return array<string,array<int,string|Rule>>
|
||||||
*/
|
*/
|
||||||
public static function buildRules(array $parameters, string $prefix): array
|
public function infer(array $parameters, string $basePrefix): array
|
||||||
{
|
{
|
||||||
$rules = [];
|
$rules = [];
|
||||||
foreach ($parameters as $parameter) {
|
foreach ($parameters as $parameter) {
|
||||||
foreach (self::buildParameterRule($parameter, $prefix) as $key => $newRules) {
|
$prefix = $basePrefix
|
||||||
|
. (empty($basePrefix) ? '' : '.')
|
||||||
|
. (empty($parameter->reflection->getAttributes(Flat::class)) ? $parameter->reflection->getName() : '');
|
||||||
|
foreach ($this->buildParameterRule($parameter, $prefix) as $key => $newRules) {
|
||||||
$rules[$key] = $newRules;
|
$rules[$key] = $newRules;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -115,22 +81,23 @@ class RuleFactory
|
||||||
/**
|
/**
|
||||||
* @return array<string, array<int, string|Rule>>
|
* @return array<string, array<int, string|Rule>>
|
||||||
*/
|
*/
|
||||||
public static function buildParameterRule(ParameterMeta $parameter, string $prefix): array
|
public function buildParameterRule(ParameterMeta $parameter, string $prefix): array
|
||||||
{
|
{
|
||||||
$type = $parameter->reflection->getType();
|
$type = $parameter->reflection->getType();
|
||||||
|
|
||||||
$root = $prefix . $parameter->reflection->getName();
|
|
||||||
if (empty($type)) {
|
if (empty($type)) {
|
||||||
return [$root => $parameter->reflection->isOptional() ? ['sometimes'] : ['required']];
|
return [$prefix => $parameter->reflection->isOptional() ? ['sometimes'] : ['required']];
|
||||||
}
|
}
|
||||||
|
|
||||||
$rules = [$root => []];
|
$rules = [$prefix => []];
|
||||||
if ($parameter->reflection->isOptional()) {
|
if (!empty($prefix)) {
|
||||||
$rules[$root][] = 'sometimes';
|
if ($parameter->reflection->isOptional()) {
|
||||||
} elseif ($type->allowsNull()) {
|
$rules[$prefix][] = 'sometimes';
|
||||||
$rules[$root][] = 'nullable';
|
} elseif ($type->allowsNull()) {
|
||||||
} else {
|
$rules[$prefix][] = 'nullable';
|
||||||
$rules[$root][] = 'required';
|
} else {
|
||||||
|
$rules[$prefix][] = 'required';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($type instanceof ReflectionUnionType) {
|
if ($type instanceof ReflectionUnionType) {
|
||||||
|
|
@ -139,53 +106,96 @@ class RuleFactory
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($type instanceof ReflectionNamedType && $name = $type->getName()) {
|
if ($type instanceof ReflectionNamedType && $name = $type->getName()) {
|
||||||
if ($globalRules = config('dto.rules.' . $name)) {
|
if ($globalRules = Config::getRules($name)) {
|
||||||
foreach ($globalRules($parameter->reflection, $parameter->tag->getType()) as $scopedPrefix => $values) {
|
foreach ($globalRules($parameter, $this) as $scopedPrefix => $values) {
|
||||||
$realPrefix = $root . $scopedPrefix;
|
$realPrefix = $prefix . $scopedPrefix;
|
||||||
$rules[$realPrefix] = array_values(array_unique(array_merge($rules[$realPrefix] ?? [], $values)));
|
$rules[$realPrefix] = array_merge($rules[$realPrefix] ?? [], $values);
|
||||||
}
|
}
|
||||||
|
return $rules;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($name === 'string') {
|
if ($name === 'string') {
|
||||||
} elseif ($name === 'bool') {
|
} elseif ($name === 'bool') {
|
||||||
$rules[$root][] = 'boolean';
|
$rules[$prefix][] = 'boolean';
|
||||||
} elseif ($name === 'int' || $name === 'float') {
|
} elseif ($name === 'int' || $name === 'float') {
|
||||||
$rules[$root][] = 'numeric';
|
$rules[$prefix][] = 'numeric';
|
||||||
} elseif ($name === 'array') {
|
} elseif ($name === 'array') {
|
||||||
$rules[$root][] = 'array';
|
$rules[$prefix][] = 'array';
|
||||||
} elseif (enum_exists($name)) {
|
} elseif (enum_exists($name)) {
|
||||||
$ref = new ReflectionClass($name);
|
$ref = new ReflectionClass($name);
|
||||||
if ($ref->isSubclassOf(BackedEnum::class)) {
|
if ($ref->isSubclassOf(BackedEnum::class)) {
|
||||||
$rules[$root][] = Rule::enum($name);
|
$rules[$prefix][] = Rule::enum($name);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
$paramsSub = self::getParametersMeta($type->getName());
|
$paramsSub = ReflectionHelper::getParametersMeta($type->getName());
|
||||||
$rules = array_merge(
|
$rules = $this->mergeRules($rules, $this->infer($paramsSub, $prefix));
|
||||||
$rules,
|
|
||||||
self::buildRules($paramsSub, $root . '.'),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($parameter->reflection->getAttributes(FromMapper::class) as $attr) {
|
|
||||||
$mapperClass = $attr->newInstance()->class;
|
|
||||||
if (method_exists($mapperClass, 'rules')) {
|
|
||||||
$subRules = App::call("$mapperClass@rules");
|
|
||||||
foreach ($subRules as $key => &$value) {
|
|
||||||
$path = empty($key) ? $root : ($root . '.' . $key);
|
|
||||||
$rules[$path] = array_values(array_unique(array_merge($rules[$path] ?? [], $value)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ($parameter->tag instanceof Param) {
|
if ($parameter->tag instanceof Param) {
|
||||||
$docblockRules = self::getRulesFromDocBlock(
|
$docblockRules = $this->getRulesFromDocBlock(
|
||||||
$parameter->tag->getType(),
|
$parameter->tag->getType(),
|
||||||
$prefix . $parameter->reflection->getName(),
|
$prefix,
|
||||||
);
|
);
|
||||||
foreach ($docblockRules as $key => &$values) {
|
$rules = $this->mergeRules($rules, $docblockRules);
|
||||||
$rules[$key] = array_values(array_unique(array_merge($rules[$key] ?? [], $values)));
|
}
|
||||||
}
|
if (empty($rules[$prefix])) {
|
||||||
|
unset($rules[$prefix]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $rules;
|
return $rules;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function __construct(public LoggerInterface $log) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param class-string $class
|
||||||
|
* @return array<string,array<int, string>>
|
||||||
|
*/
|
||||||
|
public function make(string $class): array
|
||||||
|
{
|
||||||
|
$parameters = ReflectionHelper::getParametersMeta($class);
|
||||||
|
|
||||||
|
$classReflection = new ReflectionClass($class);
|
||||||
|
$hasRulesMethod = $classReflection->hasMethod('rules');
|
||||||
|
|
||||||
|
$customRules = $hasRulesMethod ? App::call("$class::rules", []) : [];
|
||||||
|
|
||||||
|
|
||||||
|
if ($hasRulesMethod && !empty($classReflection->getMethod('rules')->getAttributes(OverwriteRules::class))) {
|
||||||
|
$rules = $customRules;
|
||||||
|
} else {
|
||||||
|
$inferredRules = RuleFactory::infer($parameters, '');
|
||||||
|
$rules = $this->mergeRules($inferredRules, $customRules);
|
||||||
|
}
|
||||||
|
$this->log->info('Constructed rules for class ' . $class, $rules);
|
||||||
|
return $rules;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,array<int, string>> $first
|
||||||
|
* @param array<string,array<int, string>> $second
|
||||||
|
* @return array<string,array<int, string>>
|
||||||
|
*/
|
||||||
|
public function mergeRules(array $first, array $second): array
|
||||||
|
{
|
||||||
|
$merged = $first;
|
||||||
|
foreach ($second as $key => $rules) {
|
||||||
|
if (isset($merged[$key])) {
|
||||||
|
$merged[$key] = array_values(array_unique(array_merge($merged[$key], $rules)));
|
||||||
|
} else {
|
||||||
|
$merged[$key] = $rules;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static self $_instance;
|
||||||
|
|
||||||
|
public static function instance(?LoggerInterface $log = null): static
|
||||||
|
{
|
||||||
|
if (empty(self::$_instance)) {
|
||||||
|
static::$_instance = new self($log ?? Log::channel(config('dto.logging.channel')));
|
||||||
|
}
|
||||||
|
return static::$_instance;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,15 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace Icefox\DTO\Support;
|
namespace Icefox\DTO\Support;
|
||||||
|
|
||||||
|
use Icefox\DTO\Attributes\CastWith;
|
||||||
|
use Icefox\DTO\Attributes\Flat;
|
||||||
|
use Icefox\DTO\Config;
|
||||||
|
use Icefox\DTO\ParameterMeta;
|
||||||
|
use Icefox\DTO\ReflectionHelper;
|
||||||
use Illuminate\Support\Facades\App;
|
use Illuminate\Support\Facades\App;
|
||||||
use ReflectionNamedType;
|
use ReflectionNamedType;
|
||||||
use ReflectionParameter;
|
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,42 +26,50 @@ class ValueFactory
|
||||||
{
|
{
|
||||||
public static function constructObject(string $className, mixed $rawValue): object
|
public static function constructObject(string $className, mixed $rawValue): object
|
||||||
{
|
{
|
||||||
if ($mapper = config('dto.mappers.' . $className, null)) {
|
if ($mapper = Config::getCaster($className)) {
|
||||||
return $mapper($rawValue);
|
return $mapper($rawValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (is_array($rawValue)) {
|
// Plain values or numeric arrays are passed as a single parameter to the constructor
|
||||||
return App::makeWith($className, $rawValue);
|
if (!is_array($rawValue) || array_key_exists(0, $rawValue)) {
|
||||||
|
return new $className($className);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new $className($rawValue);
|
// Associative arrays leverage Laravel service container
|
||||||
|
return App::makeWith($className, $rawValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function resolveTypedValue(mixed $rawValue, Type $type): mixed
|
public static function resolveAnnotatedValue(Type $type, mixed $rawValue): mixed
|
||||||
{
|
{
|
||||||
if ($type instanceof Nullable) {
|
if ($type instanceof Nullable) {
|
||||||
$type = $type->getActualType();
|
$type = $type->getActualType();
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($type instanceof Generic) {
|
|
||||||
$types = $type->getTypes();
|
|
||||||
$innerType = count($types) === 2 ? $types[1] : $types[0];
|
|
||||||
$result = [];
|
|
||||||
foreach ($rawValue as $key => $value) {
|
|
||||||
$result[$key] = self::resolveTypedValue($value, $innerType);
|
|
||||||
}
|
|
||||||
return new ($type->getFqsen()->__toString())($result);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($type instanceof AbstractList) {
|
if ($type instanceof AbstractList) {
|
||||||
$innerType = $type->getValueType();
|
$innerType = $type->getValueType();
|
||||||
$result = [];
|
$result = [];
|
||||||
foreach ($rawValue as $key => $value) {
|
foreach ($rawValue as $key => $value) {
|
||||||
$result[$key] = self::resolveTypedValue($value, $innerType);
|
$result[$key] = self::resolveAnnotatedValue($innerType, $value);
|
||||||
}
|
}
|
||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($type instanceof Generic) {
|
||||||
|
$types = $type->getTypes();
|
||||||
|
$innerType = count($types) === 2 ? $types[1] : $types[0];
|
||||||
|
if (is_array($rawValue)) {
|
||||||
|
$innerValues = [];
|
||||||
|
foreach ($rawValue as $key => $value) {
|
||||||
|
$innerValues[$key] = self::resolveAnnotatedValue($innerType, $value);
|
||||||
|
}
|
||||||
|
return array_key_exists(0, $rawValue)
|
||||||
|
? new ($type->getFqsen()->__toString())($innerValues)
|
||||||
|
: App::makeWith($type->getFqsen()->__toString(), $innerValues);
|
||||||
|
}
|
||||||
|
$value = self::resolveAnnotatedValue($innerType, $rawValue);
|
||||||
|
return new ($type->getFqsen()->__toString())($value);
|
||||||
|
}
|
||||||
|
|
||||||
if ($type instanceof Boolean) {
|
if ($type instanceof Boolean) {
|
||||||
return boolval($rawValue);
|
return boolval($rawValue);
|
||||||
}
|
}
|
||||||
|
|
@ -69,33 +83,70 @@ class ValueFactory
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($type instanceof Object_) {
|
if ($type instanceof Object_) {
|
||||||
return self::constructObject($type->getFqsen()->__toString(), $rawValue);
|
return self::make($type->getFqsen()->__toString(), $rawValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $rawValue;
|
return $rawValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function resolveValue(mixed $rawValue, ?Type $type, ReflectionParameter $reflection): mixed
|
public static function resolveDeclaredTypeValue(ReflectionNamedType $parameter, mixed $rawValue): mixed
|
||||||
{
|
{
|
||||||
if ($reflection->allowsNull() && is_null($rawValue)) {
|
return match ($parameter->getName()) {
|
||||||
return null;
|
'string' => $rawValue,
|
||||||
}
|
'bool' => boolval($rawValue),
|
||||||
|
'int' => intval($rawValue),
|
||||||
|
'float' => floatval($rawValue),
|
||||||
|
'array' => $rawValue,
|
||||||
|
default => self::make($parameter->getName(), $rawValue),
|
||||||
|
|
||||||
if (is_null($type)) {
|
};
|
||||||
$reflectedType = $reflection->getType();
|
}
|
||||||
if ($reflectedType instanceof ReflectionNamedType && $name = $reflectedType->getName()) {
|
|
||||||
return match ($name) {
|
public static function make(string $class, array $input): object
|
||||||
'string' => $rawValue,
|
{
|
||||||
'bool' => boolval($rawValue),
|
$parameters = ReflectionHelper::getParametersMeta($class);
|
||||||
'int' => intval($rawValue),
|
$arguments = [];
|
||||||
'float' => floatval($rawValue),
|
foreach ($parameters as $parameter) {
|
||||||
'array' => $rawValue,
|
$name = $parameter->reflection->getName();
|
||||||
default => self::constructObject($name, $rawValue),
|
|
||||||
};
|
$parameterArgs = empty($parameter->reflection->getAttributes(Flat::class)) ? ($input[$name] ?? null) : $input;
|
||||||
|
|
||||||
|
if (is_null($parameterArgs)) {
|
||||||
|
if ($parameter->reflection->allowsNull()) {
|
||||||
|
$arguments[$name] = null;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
return $rawValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
return self::resolveTypedValue($rawValue, $type);
|
if ($caster = $parameter->reflection->getAttributes(CastWith::class)[0] ?? null) {
|
||||||
|
$caster = $caster->newInstance()->class;
|
||||||
|
$arguments[$name] = App::call("$caster@cast", ['data' => $parameterArgs]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$parameterClass = $parameter->reflection->getClass()?->getName();
|
||||||
|
|
||||||
|
$type = $parameter->tag?->getType();
|
||||||
|
if (empty($parameterClass) && $type instanceof Object_) {
|
||||||
|
$parameterClass = $type->getFqsen();
|
||||||
|
}
|
||||||
|
if (!empty($parameterClass) && $caster = config('dto.cast.' . $parameterClass, null)) {
|
||||||
|
$arguments[$name] = App::call($caster, ['data' => $parameterArgs]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($parameter->tag instanceof Param) {
|
||||||
|
$arguments[$name] = self::resolveAnnotatedValue($type, $parameterArgs);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($parameter->reflection->getType() instanceof ReflectionNamedType) {
|
||||||
|
$arguments[$name] = self::resolveDeclaredTypeValue($parameter->reflection->getType(), $parameterArgs);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$arguments[$name] = $parameterArgs;
|
||||||
|
}
|
||||||
|
return App::makeWith($class, $arguments);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
use Icefox\DTO\Factories\CollectionFactory;
|
|
||||||
use Illuminate\Support\Collection;
|
|
||||||
|
|
||||||
return [
|
|
||||||
'rules' => [
|
|
||||||
Collection::class => CollectionFactory::rules(...),
|
|
||||||
],
|
|
||||||
'default' => [
|
|
||||||
'factories' => [
|
|
||||||
],
|
|
||||||
],
|
|
||||||
];
|
|
||||||
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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
@ -7,9 +7,9 @@ use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
class CarbonPeriodMapper
|
class CarbonPeriodMapper
|
||||||
{
|
{
|
||||||
public function map(mixed $value): CarbonPeriodImmutable
|
public function cast(mixed $data): CarbonPeriodImmutable
|
||||||
{
|
{
|
||||||
return new CarbonPeriodImmutable(Carbon::parse($value['start']), Carbon::parse($value['end']));
|
return new CarbonPeriodImmutable(Carbon::parse($data['start']), Carbon::parse($data['end']));
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function rules(): array
|
public static function rules(): array
|
||||||
|
|
|
||||||
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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,8 +4,8 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace Tests\Classes;
|
namespace Tests\Classes;
|
||||||
|
|
||||||
|
use Carbon\Carbon;
|
||||||
use Icefox\DTO\DataObject;
|
use Icefox\DTO\DataObject;
|
||||||
use Illuminate\Support\Carbon;
|
|
||||||
|
|
||||||
readonly class ObjectWithoutMapper
|
readonly class ObjectWithoutMapper
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ declare(strict_types=1);
|
||||||
namespace Tests\Classes;
|
namespace Tests\Classes;
|
||||||
|
|
||||||
use Carbon\CarbonPeriodImmutable;
|
use Carbon\CarbonPeriodImmutable;
|
||||||
use Icefox\DTO\Attributes\FromMapper;
|
use Icefox\DTO\Attributes\CastWith;
|
||||||
use Icefox\DTO\DataObject;
|
use Icefox\DTO\DataObject;
|
||||||
|
|
||||||
readonly class WithMapperObject
|
readonly class WithMapperObject
|
||||||
|
|
@ -13,7 +13,7 @@ readonly class WithMapperObject
|
||||||
use DataObject;
|
use DataObject;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
#[FromMapper(CarbonPeriodMapper::class)]
|
#[CastWith(CarbonPeriodMapper::class)]
|
||||||
public CarbonPeriodImmutable $period,
|
public CarbonPeriodImmutable $period,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,12 @@
|
||||||
|
|
||||||
namespace Tests;
|
namespace Tests;
|
||||||
|
|
||||||
|
use Icefox\DTO\Log;
|
||||||
use Icefox\DTO\Support\RuleFactory;
|
use Icefox\DTO\Support\RuleFactory;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
use Tests\Classes\ArrayDataObject;
|
use Tests\Classes\ArrayDataObject;
|
||||||
use Tests\Classes\CollectionDataObject;
|
use Tests\Classes\CollectionDataObject;
|
||||||
use Tests\Classes\FromInputObject;
|
use Tests\Classes\FromInputObject;
|
||||||
use Tests\Classes\ObjectWithoutMapper;
|
|
||||||
use Tests\Classes\OptionalData;
|
use Tests\Classes\OptionalData;
|
||||||
use Tests\Classes\OptionalNullableData;
|
use Tests\Classes\OptionalNullableData;
|
||||||
use Tests\Classes\PrimitiveData;
|
use Tests\Classes\PrimitiveData;
|
||||||
|
|
@ -16,7 +16,7 @@ use Tests\Classes\WithMapperObject;
|
||||||
|
|
||||||
describe('primitive data test', function () {
|
describe('primitive data test', function () {
|
||||||
it('creates required rules', function () {
|
it('creates required rules', function () {
|
||||||
$rules = RuleFactory::buildRules(RuleFactory::getParametersMeta(PrimitiveData::class), '');
|
$rules = (new RuleFactory(new Log()))->make(PrimitiveData::class);
|
||||||
expect($rules)->toMatchArray([
|
expect($rules)->toMatchArray([
|
||||||
'string' => ['required'],
|
'string' => ['required'],
|
||||||
'int' => ['required', 'numeric'],
|
'int' => ['required', 'numeric'],
|
||||||
|
|
@ -42,7 +42,7 @@ describe('primitive data test', function () {
|
||||||
|
|
||||||
describe('optional data', function () {
|
describe('optional data', function () {
|
||||||
it('creates optional rules', function () {
|
it('creates optional rules', function () {
|
||||||
$rules = RuleFactory::buildRules(RuleFactory::getParametersMeta(OptionalData::class), '');
|
$rules = (new RuleFactory(new Log()))->make(OptionalData::class);
|
||||||
expect($rules)->toMatchArray([
|
expect($rules)->toMatchArray([
|
||||||
'string' => ['sometimes'],
|
'string' => ['sometimes'],
|
||||||
'int' => ['sometimes', 'numeric'],
|
'int' => ['sometimes', 'numeric'],
|
||||||
|
|
@ -63,7 +63,7 @@ describe('optional data', function () {
|
||||||
|
|
||||||
describe('nullable data', function () {
|
describe('nullable data', function () {
|
||||||
it('creates nullable rules', function () {
|
it('creates nullable rules', function () {
|
||||||
$rules = RuleFactory::buildRules(RuleFactory::getParametersMeta(OptionalNullableData::class), '');
|
$rules = (new RuleFactory(new Log()))->make(OptionalNullableData::class);
|
||||||
expect($rules)->toMatchArray([
|
expect($rules)->toMatchArray([
|
||||||
'string' => ['required'],
|
'string' => ['required'],
|
||||||
'int' => ['nullable', 'numeric'],
|
'int' => ['nullable', 'numeric'],
|
||||||
|
|
@ -93,10 +93,7 @@ describe('nullable data', function () {
|
||||||
describe('reference other DataObject', function () {
|
describe('reference other DataObject', function () {
|
||||||
|
|
||||||
it('creates recursive rules', function () {
|
it('creates recursive rules', function () {
|
||||||
$rules = RuleFactory::buildRules(
|
$rules = (new RuleFactory(new Log()))->make(RecursiveDataObject::class);
|
||||||
RuleFactory::getParametersMeta(RecursiveDataObject::class),
|
|
||||||
'',
|
|
||||||
);
|
|
||||||
expect($rules)->toMatchArray([
|
expect($rules)->toMatchArray([
|
||||||
'string' => ['required'],
|
'string' => ['required'],
|
||||||
'extra.string' => ['required'],
|
'extra.string' => ['required'],
|
||||||
|
|
@ -109,7 +106,7 @@ describe('reference other DataObject', function () {
|
||||||
|
|
||||||
describe('primitive array', function () {
|
describe('primitive array', function () {
|
||||||
it('creates array rules', function () {
|
it('creates array rules', function () {
|
||||||
$rules = RuleFactory::buildRules(RuleFactory::getParametersMeta(ArrayDataObject::class), '');
|
$rules = (new RuleFactory(new Log()))->make(ArrayDataObject::class);
|
||||||
expect($rules)->toMatchArray([
|
expect($rules)->toMatchArray([
|
||||||
'values' => ['required', 'array'],
|
'values' => ['required', 'array'],
|
||||||
'values.*' => ['required', 'numeric'],
|
'values.*' => ['required', 'numeric'],
|
||||||
|
|
@ -120,10 +117,7 @@ describe('primitive array', function () {
|
||||||
|
|
||||||
describe('object array', function () {
|
describe('object array', function () {
|
||||||
it('creates array rules', function () {
|
it('creates array rules', function () {
|
||||||
$rules = RuleFactory::buildRules(
|
$rules = (new RuleFactory(new Log()))->make(CollectionDataObject::class);
|
||||||
RuleFactory::getParametersMeta(CollectionDataObject::class),
|
|
||||||
'',
|
|
||||||
);
|
|
||||||
expect($rules)->toMatchArray([
|
expect($rules)->toMatchArray([
|
||||||
'values' => ['required', 'array'],
|
'values' => ['required', 'array'],
|
||||||
'values.*' => ['required'],
|
'values.*' => ['required'],
|
||||||
|
|
@ -139,7 +133,7 @@ describe('can map input names', function () {
|
||||||
|
|
||||||
it('creates rules with property names', function () {
|
it('creates rules with property names', function () {
|
||||||
|
|
||||||
$rules = RuleFactory::buildRules(RuleFactory::getParametersMeta(FromInputObject::class), '');
|
$rules = (new RuleFactory(new Log()))->make(FromInputObject::class);
|
||||||
expect($rules)->toMatchArray([
|
expect($rules)->toMatchArray([
|
||||||
'text' => ['required' ],
|
'text' => ['required' ],
|
||||||
'standard' => ['required', 'numeric'],
|
'standard' => ['required', 'numeric'],
|
||||||
|
|
@ -198,11 +192,6 @@ test('failed validation throws ValidationException', function () {
|
||||||
})->throws(ValidationException::class);
|
})->throws(ValidationException::class);
|
||||||
|
|
||||||
|
|
||||||
test('tries to resolve without mapper', function () {
|
|
||||||
$object = ObjectWithoutMapper::fromArray(['date' => '1990-04-01']);
|
|
||||||
expect($object->date->isSameDay('1990-04-01'))->toBeTrue();
|
|
||||||
})->group('object-without-mapper');
|
|
||||||
|
|
||||||
test('creates collection', function () {
|
test('creates collection', function () {
|
||||||
$object = CollectionDataObject::fromArray([
|
$object = CollectionDataObject::fromArray([
|
||||||
'values' => [
|
'values' => [
|
||||||
|
|
|
||||||
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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
100
tests/Rules/RulesTest.php
Normal file
100
tests/Rules/RulesTest.php
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Rules;
|
||||||
|
|
||||||
|
use Icefox\DTO\Log;
|
||||||
|
use Icefox\DTO\ReflectionHelper;
|
||||||
|
use Icefox\DTO\Support\RuleFactory;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use Tests\Rules\WithEmptyOverwriteRules;
|
||||||
|
use Tests\Rules\WithMergedRules;
|
||||||
|
use Tests\Rules\WithOverwriteRules;
|
||||||
|
|
||||||
|
describe('rules array shape', function () {
|
||||||
|
it('returns inferred rules shape from RuleFactory::infer (inferred only)', function () {
|
||||||
|
$parameters = ReflectionHelper::getParametersMeta(WithMergedRules::class);
|
||||||
|
$rules = RuleFactory::infer($parameters, '');
|
||||||
|
|
||||||
|
expect($rules)->toBe([
|
||||||
|
'value' => ['required', 'numeric'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns inferred rules shape regardless of OverwriteRules attribute', function () {
|
||||||
|
$parameters = ReflectionHelper::getParametersMeta(WithOverwriteRules::class);
|
||||||
|
$rules = RuleFactory::infer($parameters, '');
|
||||||
|
|
||||||
|
expect($rules)->toBe([
|
||||||
|
'value' => ['required', 'numeric'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getRules method', function () {
|
||||||
|
it('returns merged rules from DataObject::getRules()', function () {
|
||||||
|
$rules = (new RuleFactory(new Log()))->make(WithMergedRules::class);
|
||||||
|
|
||||||
|
expect($rules)->toBe([
|
||||||
|
'value' => ['required', 'numeric', 'max:20'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns only custom rules from DataObject::getRules() with OverwriteRules', function () {
|
||||||
|
$rules = (new RuleFactory(new Log()))->make(WithOverwriteRules::class);
|
||||||
|
|
||||||
|
expect($rules)->toBe([
|
||||||
|
'value' => ['numeric', 'max:20'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty rules from DataObject::getRules() with OverwriteRules and no custom rules', function () {
|
||||||
|
$rules = (new RuleFactory(new Log()))->make(WithEmptyOverwriteRules::class);
|
||||||
|
|
||||||
|
expect($rules)->toBe([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('rules merging', function () {
|
||||||
|
it('merges custom rules with inferred rules by default', function () {
|
||||||
|
$object = WithMergedRules::fromArray([
|
||||||
|
'value' => 10,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($object->value)->toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails validation when merged rule is violated', function () {
|
||||||
|
expect(fn() => WithMergedRules::fromArray([
|
||||||
|
'value' => 25,
|
||||||
|
]))->toThrow(ValidationException::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails validation when required rule is violated (inferred)', function () {
|
||||||
|
expect(fn() => WithMergedRules::fromArray([
|
||||||
|
]))->toThrow(ValidationException::class);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('rules overwrite', function () {
|
||||||
|
it('uses only custom rules when OverwriteRules attribute is present', function () {
|
||||||
|
$object = WithOverwriteRules::fromArray([
|
||||||
|
'value' => 10,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($object->value)->toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails validation when custom rule is violated', function () {
|
||||||
|
expect(fn() => WithOverwriteRules::fromArray([
|
||||||
|
'value' => 25,
|
||||||
|
]))->toThrow(ValidationException::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not enforce inferred required rule when overwritten', function () {
|
||||||
|
$rules = WithOverwriteRules::rules();
|
||||||
|
expect($rules)->toHaveKey('value');
|
||||||
|
expect($rules['value'])->toBe(['numeric', 'max:20']);
|
||||||
|
});
|
||||||
|
});
|
||||||
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'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
@ -2,15 +2,11 @@
|
||||||
|
|
||||||
namespace Tests;
|
namespace Tests;
|
||||||
|
|
||||||
use Icefox\DTO\DataObjectServiceProvider;
|
use Illuminate\Contracts\Config\Repository;
|
||||||
use Orchestra\Testbench\TestCase as BaseTestCase;
|
use Monolog\Formatter\JsonFormatter;
|
||||||
|
use Orchestra\Testbench\Concerns\WithWorkbench;
|
||||||
|
|
||||||
abstract class TestCase extends BaseTestCase
|
abstract class TestCase extends \Orchestra\Testbench\TestCase
|
||||||
{
|
{
|
||||||
protected function getPackageProviders($app)
|
use WithWorkbench;
|
||||||
{
|
|
||||||
return [
|
|
||||||
DataObjectServiceProvider::class,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
use Illuminate\Foundation\Application;
|
|
||||||
|
|
||||||
use function Orchestra\Testbench\default_skeleton_path;
|
|
||||||
|
|
||||||
return Application::configure(basePath: $APP_BASE_PATH ?? default_skeleton_path())->create();
|
|
||||||
15
workbench/config/dto.php
Normal file
15
workbench/config/dto.php
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Psr\Log\LogLevel;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'logging' => [
|
||||||
|
'channel' => 'dto',
|
||||||
|
'context' => [
|
||||||
|
'rules' => LogLevel::NOTICE,
|
||||||
|
'input' => LogLevel::INFO,
|
||||||
|
'casts' => LogLevel::INFO,
|
||||||
|
'internals' => LogLevel::DEBUG,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
16
workbench/config/logging.php
Normal file
16
workbench/config/logging.php
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Monolog\Formatter\JsonFormatter;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'default' => env('LOG_CHANNEL', 'single'),
|
||||||
|
'channels' => [
|
||||||
|
'dto' => [
|
||||||
|
'driver' => 'single',
|
||||||
|
'path' => getcwd() . '/logs/dto.log',
|
||||||
|
'level' => 'debug',
|
||||||
|
'replace_placeholders' => true,
|
||||||
|
'formatter' => JsonFormatter::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