Initial Commit
This commit is contained in:
commit
88b5850c32
12 changed files with 11441 additions and 0 deletions
38
composer.json
Normal file
38
composer.json
Normal 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
10865
composer.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
61
flake.lock
generated
Normal file
61
flake.lock
generated
Normal 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
30
flake.nix
Normal 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
5
phpstan.neon.dist
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
parameters:
|
||||
paths:
|
||||
- app
|
||||
- tests
|
||||
level: 10
|
||||
17
phpunit.xml
Normal file
17
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>
|
||||
111
src/DataObject.php
Normal file
111
src/DataObject.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
55
src/DataObjectServiceProvider.php
Normal file
55
src/DataObjectServiceProvider.php
Normal 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
14
src/ParameterMeta.php
Normal 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
224
tests/DataObjectTest.php
Normal 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
5
tests/Pest.php
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<?php
|
||||
|
||||
namespace Tests;
|
||||
|
||||
uses(TestCase::class)->in('.');
|
||||
16
tests/TestCase.php
Normal file
16
tests/TestCase.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue