Initial Commit

This commit is contained in:
icefox 2026-02-18 19:06:59 -03:00
commit 88b5850c32
No known key found for this signature in database
12 changed files with 11441 additions and 0 deletions

38
composer.json Normal file
View file

@ -0,0 +1,38 @@
{
"name": "icefox/dto",
"type": "library",
"require": {
"laravel/framework": "^11.0",
"psr/log": "^3.0",
"phpdocumentor/reflection-docblock": "^6.0",
"phpdocumentor/type-resolver": "^2.0"
},
"require-dev": {
"pestphp/pest": "^4.4",
"phpstan/phpstan": "^2.1",
"friendsofphp/php-cs-fixer": "^3.94",
"orchestra/testbench": "^9.16"
},
"license": "GPL-2.0-only",
"autoload": {
"psr-4": {
"Icefox\\DTO\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"authors": [
{
"name": "icefox",
"email": "felipe@icefox.sh"
}
],
"config": {
"allow-plugins": {
"pestphp/pest-plugin": true
}
}
}

10865
composer.lock generated Normal file

File diff suppressed because it is too large Load diff

61
flake.lock generated Normal file
View file

@ -0,0 +1,61 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1771008912,
"narHash": "sha256-gf2AmWVTs8lEq7z/3ZAsgnZDhWIckkb+ZnAo5RzSxJg=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "a82ccc39b39b621151d6732718e3e250109076fa",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

30
flake.nix Normal file
View file

@ -0,0 +1,30 @@
{
description = "PHP Data Transfer Object";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs =
{
self,
nixpkgs,
flake-utils,
}:
flake-utils.lib.eachDefaultSystem (
system:
let
pkgs = nixpkgs.legacyPackages.${system};
in
{
devShells.default = pkgs.mkShell {
packages = with pkgs; [
php
php.packages.composer
];
};
}
);
}

5
phpstan.neon.dist Normal file
View file

@ -0,0 +1,5 @@
parameters:
paths:
- app
- tests
level: 10

17
phpunit.xml Normal file
View 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>

111
src/DataObject.php Normal file
View file

@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace Icefox\DTO;
use Icefox\DTO\Attributes\FromInput;
use Icefox\DTO\Attributes\FromMapper;
use Icefox\DTO\Attributes\FromRouteParameter;
use Icefox\DTO\Support\RuleFactory;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Illuminate\Validation\Validator;
use ReflectionClass;
use phpDocumentor\Reflection\DocBlock\Tags\Param;
trait DataObject
{
public static function fromRequest(Request $request): mixed
{
$reflection = new ReflectionClass(static::class);
$constructor = $reflection->getConstructor();
$input = [];
foreach ($constructor->getParameters() as $parameter) {
$parameterName = $parameter->getName();
foreach ($parameter->getAttributes(FromRouteParameter::class) as $attr) {
$name = $attr->newInstance()->name;
if ($value = $request->input($name, null)) {
$input[$parameterName] = $value;
continue 2;
}
}
}
return static::fromArray($input);
}
/**
* @param array<int,mixed> $input
*/
public static function fromArray(array $input): static
{
$parameters = RuleFactory::getParametersMeta(static::class);
foreach ($parameters as $parameter) {
$parameterName = $parameter->reflection->getName();
foreach ($parameter->reflection->getAttributes(FromInput::class) as $attr) {
if ($value = $input[$attr->newInstance()->name] ?? null) {
$input[$parameterName] = $value;
continue 2;
}
}
if ($value = $input[$parameterName] ?? null) {
$input[$parameterName] = $value;
continue;
}
if ($parameter->reflection->isDefaultValueAvailable()) {
$input[$parameterName] = $parameter->reflection->getDefaultValue();
continue;
}
}
$rules = RuleFactory::buildRules($parameters, '');
$validator = static::withValidator($input, $rules);
if ($validator->fails()) {
$exception = new ValidationException($validator);
throw $exception;
}
$mappedInput = [];
foreach ($parameters as $parameter) {
$parameterName = $parameter->reflection->getName();
if ($mapper = array_first($parameter->reflection->getAttributes(FromMapper::class))) {
$value = App::call(
[App::make($mapper->newInstance()->class), 'map'],
['value' => $validator->getValue($parameterName)],
);
$mappedInput[$parameterName] = $value;
continue;
}
$mappedInput[$parameterName] = RuleFactory::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<int,mixed> $data
* @param array<int,string|Rule> $rules
*/
public static function withValidator(array $data, array $rules): Validator
{
return App::makeWith(Validator::class, ['data' => $data, 'rules' => $rules]);
}
}

View file

@ -0,0 +1,55 @@
<?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');
$registrar = new DataObjectRegistrar($this->app);
// Register from configured namespaces or manual class list
$config = $this->getConfig();
if (!empty($config['classes'])) {
// Manual registration (zero overhead)
$registrar->registerMany($config['classes']);
} elseif (!empty($config['namespaces'])) {
// Automatic namespace scanning
$registrar->registerFromNamespaces($config['namespaces']);
}
}
/**
* Bootstrap services.
*/
public function boot(): void
{
// Commands will be registered here
}
/**
* Get DataObject configuration.
*
* @return array{namespaces: string[], classes: class-string[]}
*/
protected function getConfig(): array
{
return config('dataobject', [
'namespaces' => [
'App\DataObjects',
],
'classes' => [],
]);
}
}

14
src/ParameterMeta.php Normal file
View file

@ -0,0 +1,14 @@
<?php
namespace Icefox\DTO;
use ReflectionParameter;
use phpDocumentor\Reflection\DocBlock\Tags\Param;
readonly class ParameterMeta
{
public function __construct(
public ReflectionParameter $reflection,
public ?Param $tag,
) {}
}

224
tests/DataObjectTest.php Normal file
View file

@ -0,0 +1,224 @@
<?php
namespace Tests;
use Icefox\DTO\Support\RuleFactory;
use Illuminate\Validation\ValidationException;
use Tests\Classes\ArrayDataObject;
use Tests\Classes\CollectionDataObject;
use Tests\Classes\FromInputObject;
use Tests\Classes\ObjectWithoutMapper;
use Tests\Classes\OptionalData;
use Tests\Classes\OptionalNullableData;
use Tests\Classes\PrimitiveData;
use Tests\Classes\RecursiveDataObject;
use Tests\Classes\WithMapperObject;
describe('primitive data test', function () {
it('creates required rules', function () {
$rules = RuleFactory::buildRules(RuleFactory::getParametersMeta(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();
});
})->group('primitives');
describe('optional data', function () {
it('creates optional rules', function () {
$rules = RuleFactory::buildRules(RuleFactory::getParametersMeta(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();
});
})->group('optional');
describe('nullable data', function () {
it('creates nullable rules', function () {
$rules = RuleFactory::buildRules(RuleFactory::getParametersMeta(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();
});
})->group('nullable');
describe('reference other DataObject', function () {
it('creates recursive rules', function () {
$rules = RuleFactory::buildRules(
RuleFactory::getParametersMeta(RecursiveDataObject::class),
'',
);
expect($rules)->toMatchArray([
'string' => ['required'],
'extra.string' => ['required'],
'extra.int' => ['required', 'numeric'],
'extra.float' => ['required', 'numeric'],
'extra.bool' => ['required', 'boolean'],
]);
});
})->group('reference');
describe('primitive array', function () {
it('creates array rules', function () {
$rules = RuleFactory::buildRules(RuleFactory::getParametersMeta(ArrayDataObject::class), '');
expect($rules)->toMatchArray([
'values' => ['required', 'array'],
'values.*' => ['required', 'numeric'],
]);
});
})->group('primitive-array');
describe('object array', function () {
it('creates array rules', function () {
$rules = RuleFactory::buildRules(
RuleFactory::getParametersMeta(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'],
]);
});
})->group('object-array');
describe('can map input names', function () {
it('creates rules with property names', function () {
$rules = RuleFactory::buildRules(RuleFactory::getParametersMeta(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);
});
})->group('input-map');
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);
})->group('mapper-object');
test('failed validation throws ValidationException', function () {
$object = PrimitiveData::fromArray([
'int' => 0,
'float' => 3.14,
'bool' => true,
]);
})->throws(ValidationException::class)
->group('error');
test('tries to resolve without mapper', function () {
$object = ObjectWithoutMapper::fromArray(['date' => '1990-04-01']);
expect($object->date->isSameDay('1990-04-01'))->toBeTrue();
})->group('object-without-mapper');
test('creates collection', function () {
$object = CollectionDataObject::fromArray([
'values' => [
[
'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();
})->group('collection');

5
tests/Pest.php Normal file
View file

@ -0,0 +1,5 @@
<?php
namespace Tests;
uses(TestCase::class)->in('.');

16
tests/TestCase.php Normal file
View file

@ -0,0 +1,16 @@
<?php
namespace Tests;
use Icefox\DTO\DataObjectServiceProvider;
use Orchestra\Testbench\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase
{
protected function getPackageProviders($app)
{
return [
DataObjectServiceProvider::class,
];
}
}