feat: 직접 작성한 DTO #220

Merged
Hide_D merged 1 commits from DTO into devel 2022-05-22 16:59:09 +09:00
11 changed files with 800 additions and 0 deletions
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace sammo\DTO\Attr;
use sammo\DTO\Converter\Converter;
#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_CLASS)]
class Convert
{
public Converter $converter;
public function __construct(
public readonly array $targetTypes,
public readonly string $converterType,
array ...$args
) {
if(!is_subclass_of($converterType, \sammo\DTO\Converter\Converter::class)){
throw new \Exception("$converterType is not a subclass of \sammo\DTO\Converter\Converter");
}
$this->converter = new $converterType($targetTypes, ...$args);
}
public function convertFrom(string|array|int|float|bool|null $raw): mixed
{
return $this->converter->convertFrom($raw);
}
public function convertTo(mixed $target): string|array|int|float|bool|null {
return $this->converter->convertTo($target);
}
}
+11
View File
@@ -0,0 +1,11 @@
<?php
namespace sammo\DTO\Attr;
#[\Attribute(\Attribute::TARGET_PROPERTY)]
class JsonString
{
public function __construct(public readonly bool $emptyItemIsArray = false)
{
}
}
+11
View File
@@ -0,0 +1,11 @@
<?php
namespace sammo\DTO\Attr;
#[\Attribute(\Attribute::TARGET_PROPERTY)]
class NullIsUndefined
{
public function __construct()
{
}
}
+11
View File
@@ -0,0 +1,11 @@
<?php
namespace sammo\DTO\Attr;
#[\Attribute(\Attribute::TARGET_PROPERTY)]
class RawName
{
public function __construct(public readonly string $rawName)
{
}
}
@@ -0,0 +1,45 @@
<?php
namespace sammo\DTO\Converter;
class ArrayConverter implements Converter
{
private Converter $itemConverter;
public function __construct(private array $types, ...$args)
{
$itemTypes = array_shift($args);
if(!is_array($itemTypes)){
throw new \Exception('itemTypes is not a array');
}
$itemConverterType = array_shift($args);
if($itemConverterType === null){
$itemConverterType = DefaultConverter::class;
}
else if(!is_subclass_of($itemConverterType, Converter::class)){
throw new \Exception("$itemConverterType is not a subclass of \sammo\DTO\Converter\Converter");
}
$this->itemConverter = new $itemConverterType($itemTypes, ...$args);
}
public function convertFrom(string|array|int|float|bool|null $raw): mixed
{
if ($raw === null && array_search('null', $this->types, true) !== false) {
return null;
}
if (!is_array($raw) || !array_is_list($raw)) {
throw new \Exception('value is not a array');
}
return array_map(fn ($v) => $this->itemConverter->convertFrom($v), $raw);
}
public function convertTo(mixed $data): string|array|int|float|bool|null
{
if ($data === null && array_search('null', $this->types) !== false) {
return null;
}
if (!is_array($data) || !array_is_list($data)) {
throw new \Exception('value is not a array');
}
return array_map(fn ($v) => $this->itemConverter->convertTo($v), $data);
}
}
+8
View File
@@ -0,0 +1,8 @@
<?php
namespace sammo\DTO\Converter;
interface Converter{
public function __construct(array $types, ...$args);
public function convertFrom(string|array|int|float|bool|null $raw): mixed;
public function convertTo(mixed $data): string|array|int|float|bool|null;
}
@@ -0,0 +1,140 @@
<?php
namespace sammo\DTO\Converter;
use sammo\DTO\DTO;
class DefaultConverter implements Converter
{
public function __construct(private array $types, ...$args)
{
}
public static function convertFromItem(string $type, $raw, bool &$success): mixed
{
$success = false;
if (is_subclass_of($type, \UnitEnum::class)) {
$enumType = new \ReflectionEnum($type);
if ($enumType->isBacked()) {
$enum = $enumType->getMethod('tryFrom')->invoke(null, $raw);
if ($enum === null) {
return null;
}
$success = true;
return $enum;
}
if (!$enumType->hasCase($raw)) {
return null;
}
$success = true;
return $enumType->getCase($raw)->getValue();
}
if (is_subclass_of($type, DTO::class)) {
try {
$class = new \ReflectionClass($type);
$obj = $class->getMethod('fromArray')->invoke(null, $raw);
$success = true;
return $obj;
} catch (\Throwable) {
}
return null;
}
if ($type === 'array') {
if (!is_array($raw)) {
return null;
}
if (!array_is_list($raw)) {
throw new \Exception('value is not a array');
}
foreach ($raw as $value) {
if (is_int($value) || is_float($value) || is_string($value)) {
continue;
}
if (is_bool($value) || is_null($value)) {
continue;
}
throw new \Exception('DefaultConverter can not convert array');
}
$success = true;
return $raw;
}
if($type === 'int' && is_int($raw)){
$success = true;
return $raw;
}
if($type === 'float' && is_float($raw)){
$success = true;
return $raw;
}
if($type === 'string' && is_string($raw)){
$success = true;
return $raw;
}
if($type === 'bool' && is_bool($raw)){
$success = true;
return $raw;
}
return null;
}
public function convertFrom(string|array|int|float|bool|null $raw): mixed
{
if ($raw === null && array_search('null', $this->types, true) !== false) {
return null;
}
foreach ($this->types as $type) {
$success = false;
$value = self::convertFromItem($type, $raw, $success);
if ($success) {
return $value;
}
}
throw new \Exception('DefaultConverter can not convert');
}
public function convertTo(mixed $data): string|array|int|float|bool|null
{
if ($data === null) {
return $data;
}
if ($data instanceof \UnitEnum) {
if ($data instanceof \BackedEnum) {
return $data->value;
}
return $data->name;
}
if ($data instanceof DTO) {
return $data->toArray();
}
if (is_array($data)) {
if (!array_is_list($data)) {
throw new \Exception('value is not a array');
}
foreach ($data as $value) {
if (is_int($value) || is_float($value) || is_string($value)) {
continue;
}
if (is_bool($value) || is_null($value)) {
continue;
}
throw new \Exception('DefaultConverter can not convert array');
}
}
return $data;
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
namespace sammo\DTO\Converter;
class MapConverter implements Converter
{
private Converter $itemConverter;
public function __construct(private array $types, ...$args)
{
$itemTypes = array_shift($args);
if(!is_array($itemTypes)){
throw new \Exception('itemTypes is not a array');
}
$itemConverterType = array_shift($args);
if($itemConverterType === null){
$itemConverterType = DefaultConverter::class;
}
else if (!is_subclass_of($itemConverterType, Converter::class)) {
throw new \Exception("$itemConverterType is not a subclass of \sammo\DTO\Converter\Converter");
}
$this->itemConverter = new $itemConverterType($itemTypes, ...$args);
}
public function convertFrom(string|array|int|float|bool|null $raw): mixed
{
if ($raw === null && array_search('null', $this->types, true) !== false) {
return null;
}
if (!is_array($raw)) {
throw new \Exception('value is not a array');
}
$result = [];
foreach ($raw as $key => $value) {
$result[$key] = $this->itemConverter->convertFrom($value);
}
return $result;
}
public function convertTo(mixed $data): string|array|int|float|bool|null
{
if ($data === null && array_search('null', $this->types, true) !== false) {
return null;
}
if (!is_array($data) && !($data instanceof \Traversable)) {
throw new \Exception('value is not a array');
}
$result = [];
foreach ($data as $key => $value) {
$result[$key] = $this->itemConverter->convertTo($value);
}
return $result;
}
}
+106
View File
@@ -0,0 +1,106 @@
<?php
namespace sammo\DTO;
use sammo\DTO\Attr\Convert;
use sammo\DTO\Converter\DefaultConverter;
use sammo\Json;
abstract class DTO
{
public static function fromArray(\ArrayAccess|array $array): static
{
$reflection = new \ReflectionClass(static::class);
$args = [];
if ($array instanceof \ArrayAccess) {
$keyExists = fn (string|int $key) => $array->offsetExists($key);
} else {
$keyExists = fn (string|int $key) => array_key_exists($key, $array);
}
foreach ($reflection->getProperties(
\ReflectionProperty::IS_PUBLIC
) as $property) {
$attrs = Util\Util::getAttrs($property);
$name = $property->getName();
$rawName = $name;
if (key_exists(Attr\RawName::class, $attrs)) {
$rawAttr = $attrs[Attr\RawName::class];
$attr = new Attr\RawName(...$rawAttr->getArguments());
$rawName = $attr->rawName;
}
if (!$keyExists($rawName)) {
if ($property->hasDefaultValue()) {
$args[$name] = $property->getDefaultValue();
} else if ($property->getType()->allowsNull()) {
$args[$name] = null;
} else {
throw new \Exception("Missing property: {$name}");
}
continue;
}
$value = $array[$rawName];
if (key_exists(Attr\JsonString::class, $attrs)) {
$rawAttr = $attrs[Attr\JsonString::class];
$attr = new Attr\JsonString(...$rawAttr->getArguments());
$value = Json::decode($value);
}
$propTypes = Util\Util::getPropTypes($property);
if (key_exists(Attr\Convert::class, $attrs)) {
$rawAttr = $attrs[Attr\Convert::class];
$converter = new Convert($propTypes, ...$rawAttr->getArguments());
} else {
$converter = new Convert($propTypes, DefaultConverter::class);
}
$value = $converter->convertFrom($value);
$args[$name] = $value;
}
$object = $reflection->newInstanceArgs($args);
return $object;
}
public function toArray(): array
{
$reflection = new \ReflectionClass($this::class);
$result = [];
foreach ($reflection->getProperties(\ReflectionProperty::IS_PUBLIC) as $property) {
$value = $property->getValue($this);
$attrs = Util\Util::getAttrs($property);
$name = $property->getName();
if (key_exists(Attr\RawName::class, $attrs)) {
$rawAttr = $attrs[Attr\RawName::class];
$attr = new Attr\RawName(...$rawAttr->getArguments());
$name = $attr->rawName;
}
$propTypes = Util\Util::getPropTypes($property);
if (key_exists(Attr\Convert::class, $attrs)) {
$converter = new Convert($propTypes, ...$attrs[Attr\Convert::class]->getArguments());
} else {
$converter = new Convert($propTypes, DefaultConverter::class);
}
$value = $converter->convertTo($value);
if (key_exists(Attr\JsonString::class, $attrs)) {
$rawAttr = $attrs[Attr\JsonString::class];
$attr = new Attr\JsonString(...$rawAttr->getArguments());
$value = Json::encode($value, $attr->emptyItemIsArray ? JSON::EMPTY_ARRAY_IS_DICT : 0);
}
if ($value === null && key_exists(Attr\NullIsUndefined::class, $attrs)) {
continue;
}
$result[$name] = $value;
}
return $result;
}
}
+55
View File
@@ -0,0 +1,55 @@
<?php
namespace sammo\DTO\Util;
use Ds\Map;
class Util{
private function __construct()
{
}
/**
* @return array<string,\ReflectionAttribute>
*/
public static function getAttrs(\ReflectionProperty $prop): array{
$result = [];
foreach($prop->getAttributes() as $attr){
$result[$attr->getName()] = $attr;
}
return $result;
}
/**
* @return array<string>
*/
public static function getPropTypes(\ReflectionProperty $prop): array{
$result = [];
$type = $prop->getType();
if($type === null){
throw new \Exception("Property {$prop->getName()} has no type");
}
if($type->allowsNull()){
$result[] = 'null';
}
if($type instanceof \ReflectionIntersectionType){
throw new \Exception("Intersection types are not supported");
}
if($type instanceof \ReflectionNamedType){
$result[] = $type->getName();
return $result;
}
if($type instanceof \ReflectionUnionType){
foreach($type->getTypes() as $type){
$result[] = $type->getName();
}
return $result;
}
throw new \sammo\MustNotBeReachedException;
}
}
+329
View File
@@ -0,0 +1,329 @@
<?php
use sammo\DTO\Attr\Convert;
use sammo\DTO\DTO;
use sammo\DTO\Attr\JsonString;
use sammo\DTO\Attr\NullIsUndefined;
use sammo\DTO\Converter\ArrayConverter;
use sammo\DTO\Converter\Converter;
use sammo\DTO\Converter\MapConverter;
use sammo\Json;
class TypeA extends DTO
{
public function __construct(
public string $a,
public string $b,
public bool $c,
public ?int $d,
public float $e,
) {
}
}
class TypeBA extends DTO
{
public function __construct(
public int $ba1,
public int $ba2,
) {
}
}
class TypeB extends DTO
{
public function __construct(
public string $a,
public TypeBA $ba,
) {
}
}
class TypeC extends DTO
{
public function __construct(
public string $a,
#[JsonString]
public TypeBA $ba,
) {
}
}
enum EnumA
{
case A;
case B;
case cc;
}
class TypeD extends DTO
{
public function __construct(
public ?string $a,
public EnumA $b,
) {
}
}
class TypeE extends DTO
{
public function __construct(
public ?string $a,
#[NullIsUndefined]
public ?string $b,
) {
}
}
class ConverterDouble implements Converter
{
public function __construct(array $types, ...$args)
{
}
public function convertFrom(string|array|int|float|bool|null $raw): mixed
{
return $raw * 2;
}
public function convertTo(mixed $data): string|array|int|float|bool|null
{
return $data / 2;
}
}
class TypeF extends DTO
{
public function __construct(
public ?string $a,
#[Convert(ConverterDouble::class)]
public int $b,
) {
}
}
class TypeArr extends DTO
{
public function __construct(
public array $a,
public array $b,
) {
}
}
class TypeArrConverter extends DTO
{
public function __construct(
public array $a,
#[Convert(ArrayConverter::class, ['string'])]
public array $b,
) {
}
}
class TypeMap extends DTO
{
public function __construct(
public int $a,
#[Convert(MapConverter::class, ['null', 'string'])]
public array $b,
) {
}
}
class TypeNestedMap extends DTO
{
public function __construct(
public int $a,
#[Convert(MapConverter::class, ['null', TypeBA::class])]
public array $b,
) {
}
}
class DTOTest extends PHPUnit\Framework\TestCase
{
public function testBasic()
{
$rawType = [
'a' => '123',
'b' => 'aa',
'c' => false,
'd' => null,
'e' => 123.123,
];
$obj = TypeA::fromArray($rawType);
$testType = $obj->toArray();
$this->assertEquals($rawType, $testType);
}
public function testNested()
{
$rawType = [
'a' => '123',
'ba' => [
'ba1' => 3,
'ba2' => 4,
]
];
$obj = TypeB::fromArray($rawType);
$testType = $obj->toArray();
$this->assertEquals($rawType, $testType);
}
public function testNestedJson()
{
$x = Json::encode([
'ba1' => 3,
'ba2' => 4,
]);
$rawType = [
'a' => '123',
'ba' => $x
];
$obj = TypeC::fromArray($rawType);
$testType = $obj->toArray();
$this->assertEquals($rawType, $testType);
}
public function testEnum()
{
$rawType = [
'a' => '123',
'b' => 'B',
];
$obj = TypeD::fromArray($rawType);
$testType = $obj->toArray();
$this->assertEquals($rawType, $testType);
}
public function testNull()
{
$rawType = [
'a' => null,
];
$obj = TypeE::fromArray($rawType);
$testType = $obj->toArray();
$this->assertEquals($rawType, $testType);
}
public function testConverter()
{
$rawType = [
'a' => '123',
'b' => 123,
];
$obj = TypeF::fromArray($rawType);
$this->assertEquals($obj->b, 246);
$testType = $obj->toArray();
$this->assertEquals($rawType, $testType);
}
public function testNullIsUndefined()
{
$rawType = [
'a' => null,
];
$obj = TypeE::fromArray($rawType);
$this->assertEquals($obj->a, null);
$this->assertEquals($obj->b, null);
$testType = $obj->toArray();
$this->assertEquals($rawType, $testType);
}
public function testArr()
{
$rawType = [
'a' => [1, 2, 3],
'b' => [4, 5, 6],
];
$obj = TypeArr::fromArray($rawType);
$testType = $obj->toArray();
$this->assertEquals($rawType, $testType);
}
public function testArrConverterErr()
{
$this->expectException(Exception::class);
$rawType = [
'a' => [1, 2, 3],
'b' => [4, 5, 6],
];
$obj = TypeArrConverter::fromArray($rawType);
$testType = $obj->toArray();
$this->assertEquals($rawType, $testType);
}
public function testArrConverterErr2()
{
$this->expectException(Exception::class);
$rawType = [
'a' => [1, 2, 3],
'b' => [4 => 6, 5 => 2, 6 => 3],
];
$obj = TypeArrConverter::fromArray($rawType);
$testType = $obj->toArray();
$this->assertEquals($rawType, $testType);
}
public function testArrConverter()
{
$rawType = [
'a' => [1, 2, 3],
'b' => ['1', '2', '3'],
];
$obj = TypeArrConverter::fromArray($rawType);
$testType = $obj->toArray();
$this->assertEquals($rawType, $testType);
}
public function testMap(){
$rawType = [
'a' => 1,
'b' => [
'1' => '1',
'2' => null,
'ba' => '3',
],
];
$obj = TypeMap::fromArray($rawType);
$testType = $obj->toArray();
$this->assertEquals($rawType, $testType);
}
public function testNestedMap(){
$rawType = [
'a' => 1,
'b' => [
'aaa' => [
'ba1' => 1,
'ba2' => 2,
],
'xed' => null,
'ccc' => [
'ba1' => 3,
'ba2' => 4,
],
],
];
$obj = TypeNestedMap::fromArray($rawType);
$testType = $obj->toArray();
$this->assertEquals($rawType, $testType);
}
}