├── tests ├── Unit │ └── .gitkeep ├── Fixtures │ ├── Money.php │ ├── Skill.php │ ├── PlusMoney.php │ ├── Example.php │ ├── Example2.php │ ├── Example3.php │ ├── Controller.php │ └── User.php └── Feature │ └── ControllerTest.php ├── .gitignore ├── src ├── DataMappingException.php ├── RequestCommand.php ├── Dto │ ├── Money.php │ └── UserDto.php ├── CommandObjectValidator.php ├── ServiceProvider.php ├── SomeValidator.php ├── CommandObjectMapper.php ├── CommandObject.php └── DataTransferObjectMapper.php ├── .github └── workflows │ ├── code-style.yml │ └── run-tests.yml ├── phpunit.xml.dist ├── LICENSE ├── composer.json └── README.md /tests/Unit/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | /composer.lock 3 | /.phpunit.result.cache 4 | /.vscode 5 | -------------------------------------------------------------------------------- /src/DataMappingException.php: -------------------------------------------------------------------------------- 1 | amount; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /tests/Fixtures/Skill.php: -------------------------------------------------------------------------------- 1 | server; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/RequestCommand.php: -------------------------------------------------------------------------------- 1 | amount = $amount + 1000; 12 | } 13 | 14 | public function getAmount(): int 15 | { 16 | return $this->amount; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Dto/Money.php: -------------------------------------------------------------------------------- 1 | amount = $amount; 15 | } 16 | 17 | public function getAmount(): int 18 | { 19 | return $this->amount; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/CommandObjectValidator.php: -------------------------------------------------------------------------------- 1 | number = $data['example1'] + 1000; 14 | $this->string = $data['example2']; 15 | } 16 | 17 | public function getNumber(): int 18 | { 19 | return $this->number; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /tests/Fixtures/Example2.php: -------------------------------------------------------------------------------- 1 | number = $firstArgument + 1000; 14 | $this->string = $secondArgument; 15 | } 16 | 17 | public function getNumber(): int 18 | { 19 | return $this->number; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/ServiceProvider.php: -------------------------------------------------------------------------------- 1 | resolving(function ($object, $app) { 14 | if ($object instanceof RequestCommand) { 15 | return CommandObjectMapper::mapping($object, CommandObjectMapper::getMappingData($app->make(Request::class))); 16 | } 17 | }); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.github/workflows/code-style.yml: -------------------------------------------------------------------------------- 1 | name: code-style 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches-ignore: 7 | - "dependabot/npm_and_yarn/*" 8 | jobs: 9 | pint: 10 | runs-on: ubuntu-latest 11 | name: Pint 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v4 15 | with: 16 | fetch-depth: 2 17 | 18 | - name: "laravel-pint" 19 | uses: aglipanci/laravel-pint-action@0.1.0 20 | with: 21 | preset: laravel 22 | 23 | - name: Commit changes 24 | uses: stefanzweifel/git-auto-commit-action@v4 25 | with: 26 | commit_message: PHP Linting (Pint) 27 | skip_fetch: true 28 | -------------------------------------------------------------------------------- /tests/Fixtures/Example3.php: -------------------------------------------------------------------------------- 1 | number = $firstArgument + 1000; 16 | $this->string = $secondArgument; 17 | $this->thirdArg = $thirdArgument; 18 | } 19 | 20 | public function getString(): string 21 | { 22 | return $this->string; 23 | } 24 | 25 | public function getThirdArg(): ?string 26 | { 27 | return $this->thirdArg; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | 10 | ./tests/Feature 11 | 12 | 13 | ./tests/Unit 14 | 15 | 16 | 17 | 18 | 19 | ./src 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /.github/workflows/run-tests.yml: -------------------------------------------------------------------------------- 1 | name: run-tests 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches-ignore: 7 | - "dependabot/npm_and_yarn/*" 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | fail-fast: true 15 | matrix: 16 | os: [ubuntu-latest] 17 | php: [8.1, 8.2, 8.3] 18 | 19 | name: PHP ${{ matrix.php }} 20 | 21 | steps: 22 | - name: Checkout code 23 | uses: actions/checkout@v4 24 | 25 | - name: Setup PHP 26 | uses: shivammathur/setup-php@v2 27 | with: 28 | php-version: ${{ matrix.php }} 29 | coverage: none 30 | 31 | - name: Validate composer.json and composer.lock 32 | run: composer validate 33 | 34 | - name: Install dependencies 35 | run: composer install --prefer-dist --no-progress --no-suggest 36 | 37 | - name: Execute tests 38 | run: composer test 39 | -------------------------------------------------------------------------------- /src/SomeValidator.php: -------------------------------------------------------------------------------- 1 | setData($mappingData); 15 | $commandObject->validation($validator); 16 | 17 | if (! $commandObject->hasErrors()) { 18 | $mapper->mapping(array_merge($mappingData, $validator->getValidatedData()), $commandObject); 19 | } 20 | 21 | // mapping 에러가 있다면 commandObject 에러과 합침 22 | if ($mapper->hasErrors()) { 23 | $commandObject->setErrors(...$mapper->getErrors()); 24 | } 25 | 26 | // commandObject의 validator를 설정한다. 27 | return $commandObject; 28 | } 29 | 30 | public static function getMappingData(Request $request) 31 | { 32 | $content = $request->getContent(); 33 | //request 내용이 json content면 json_decode 한 후에 mapping. 34 | if (! empty($request) && $request->isJson()) { 35 | $jsonData = @json_decode($content, true); 36 | 37 | if (json_last_error() === JSON_ERROR_NONE) { 38 | return $jsonData; 39 | } else { 40 | parse_str($content, $mappingData); 41 | 42 | return $mappingData; 43 | } 44 | // json요청이 아니면 $request->all() 로 받아온 데이터 전체를 mapping 시킨다. 45 | } 46 | 47 | return $request->all(); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Dto/UserDto.php: -------------------------------------------------------------------------------- 1 | ['required', 'xss_clean'], 36 | ]; 37 | } 38 | 39 | public function getUserName(): string 40 | { 41 | return $this->userName; 42 | } 43 | 44 | public function getUserAge(): int 45 | { 46 | return $this->userAge; 47 | } 48 | 49 | public function getEyesight(): float 50 | { 51 | return $this->eyesight; 52 | } 53 | 54 | public function getSkill(): array 55 | { 56 | return $this->skill; 57 | } 58 | 59 | public function isMarried(): bool 60 | { 61 | return $this->married; 62 | } 63 | 64 | public function getNullable(): ?string 65 | { 66 | return $this->nullable; 67 | } 68 | 69 | public function getUserBirth(): DateTime 70 | { 71 | return $this->userBirth; 72 | } 73 | 74 | public function getMoney(): Money 75 | { 76 | return $this->money; 77 | } 78 | 79 | /** 80 | * @return Money[] 81 | */ 82 | public function getMoneyList(): array 83 | { 84 | return $this->moneyList; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /tests/Feature/ControllerTest.php: -------------------------------------------------------------------------------- 1 | expectNotToPerformAssertions(); 13 | 14 | $_REQUEST = [ 15 | 'name' => 'styoo', 16 | 'age' => 35, 17 | 'birthDay' => '2021-01-01 15:00:02', 18 | 'eyesight' => '0.8', 19 | 'correctArray' => [1, 2, 3], 20 | 'inCorrectArray' => 1, 21 | 'money' => '1,000', 22 | 'moneyList' => ['1,000', '2,000'], 23 | 'plus_money' => '1,000', 24 | 'plus_money_list' => ['1,000', '2,000'], 25 | ' skill ' => ['server' => 'php', 'client' => 'js'], 26 | 'skillList' => [ 27 | ['server' => 'php5', 'client' => 'js'], 28 | ['server' => 'php7', 'client' => 'js'], 29 | ], 30 | 'example' => ['example1' => 500, 'example2' => 'string'], 31 | 'exampleList' => [ 32 | ['example1' => 500, 'example2' => 'string'], 33 | ['example1' => 1000, 'example2' => 'string'], 34 | ], 35 | 'example2' => ['secondArgument' => 'example', 'firstArgument' => 1000], // If there is a key value, the order is not important. like namedArgument on php 8 // 36 | 'example21' => [1000, 'example'], 37 | 'example3' => ['firstArgument' => 1000, 'thirdArgument' => 'third'], 38 | 'example31' => [1000], 39 | 'example4' => ['firstArgument' => 1000, 'secondArgument' => 'customValue'], 40 | 'example41' => [1000, 'customValue', 'third'], 41 | 42 | ]; 43 | 44 | $class = new Controller(); 45 | 46 | $class->example(); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/CommandObject.php: -------------------------------------------------------------------------------- 1 | setRules($this->rule()); 20 | if (! $validator->run()) { 21 | $this->commandObjectErrors = array_merge($this->commandObjectErrors, $validator->getValidError()); 22 | } 23 | } 24 | 25 | public function hasErrors(): bool 26 | { 27 | return ! empty($this->commandObjectErrors); 28 | } 29 | 30 | /** 31 | * @return false|mixed 32 | */ 33 | public function getError(string $key) 34 | { 35 | return $this->commandObjectErrors[$key] ?: false; 36 | } 37 | 38 | public function getErrors(): array 39 | { 40 | return $this->commandObjectErrors; 41 | } 42 | 43 | public function setErrors(string ...$errors): void 44 | { 45 | foreach ($errors as $key => $error) { 46 | $this->commandObjectErrors[$key] = $error; 47 | } 48 | } 49 | 50 | /** 51 | * Specify data which should be serialized to JSON 52 | * 53 | * @link https://php.net/manual/en/jsonserializable.jsonserialize.php 54 | * 55 | * @return mixed data which can be serialized by json_encode, 56 | * which is a value of any type other than a resource. 57 | * 58 | * @since 5.4 59 | */ 60 | public function jsonSerialize(): array 61 | { 62 | $reflectionChildClass = new ReflectionClass($this); 63 | $properties = $reflectionChildClass->getProperties(); 64 | 65 | $json = []; 66 | foreach ($properties as $property) { 67 | $property->setAccessible(true); 68 | if ($property->isInitialized($this)) { 69 | $json[$property->getName()] = $property->getValue($this); 70 | } 71 | } 72 | unset($json['commandObjectErrors']); 73 | 74 | return $json; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /tests/Fixtures/Controller.php: -------------------------------------------------------------------------------- 1 | mapping($_REQUEST, User::class)->getClass(); 16 | if ($mapper->hasErrors()) { 17 | throw new Exception(var_export($mapper->getErrors(), true)); 18 | } 19 | echo $user->getName() === 'styoo'; 20 | echo $user->getAge() === 35; 21 | echo $user->getCorrectArray() === [1, 2, 3]; 22 | echo $user->getInCorrectArray() === [1]; 23 | echo $user->getEyesight() === 0.8; 24 | echo $user->getMoney()->getAmount() === 1000; 25 | echo $user->getPlusMoney()->getAmount() === 2000; 26 | echo $user->getAllowNull() === null; 27 | echo $user->getSkill()->getServer() === 'php'; 28 | echo $user->getExample()->getNumber() === 1500; 29 | 30 | foreach ($user->getSkillList() as $skill) { 31 | echo $skill->getServer(); 32 | } 33 | foreach ($user->getMoneyList() as $money) { 34 | echo $money->getAmount(); 35 | } 36 | foreach ($user->getPlusMoneyList() as $plusMoney) { 37 | echo $plusMoney->getAmount(); 38 | } 39 | foreach ($user->getExampleList() as $example) { 40 | echo $example->getNumber(); 41 | } 42 | 43 | echo $user->getExample2()->getNumber() === 2000; 44 | echo $user->getExample21()->getNumber() === 2000; 45 | echo $user->getExample3()->getString() === 'defaultValue'; 46 | echo $user->getExample31()->getString() === 'defaultValue'; 47 | echo $user->getExample3()->getThirdArg() === 'third'; 48 | echo $user->getExample31()->getThirdArg() === null; 49 | 50 | echo $user->getExample4()->getString() === 'customValue'; 51 | echo $user->getExample41()->getString() === 'customValue'; 52 | echo $user->getExample4()->getThirdArg() === null; 53 | echo $user->getExample41()->getThirdArg() === 'third'; 54 | 55 | echo var_export($user->getBirthDay(), true); 56 | } 57 | 58 | // If there is a provider layer in the framework, 59 | // you can apply this mapper to implement it like a Command Object. 60 | public function index(User $user) 61 | { 62 | /** @disregard P1013 */ 63 | if ($user->hasErrors()) { 64 | /** @disregard P1013 */ 65 | throw new Exception(var_export($user->getErrors(), true)); 66 | } 67 | 68 | $user->getName() === 'styoo'; 69 | $user->getAge() === 35; 70 | $user->getMoney()->getAmount() === 1000; 71 | 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /tests/Fixtures/User.php: -------------------------------------------------------------------------------- 1 | name; 67 | } 68 | 69 | public function getEyesight(): float 70 | { 71 | return $this->eyesight; 72 | } 73 | 74 | public function getBirthDay(): DateTime 75 | { 76 | return $this->birthDay; 77 | } 78 | 79 | public function getAge(): int 80 | { 81 | return $this->age; 82 | } 83 | 84 | public function getCorrectArray(): array 85 | { 86 | return $this->correctArray; 87 | } 88 | 89 | public function getInCorrectArray(): array 90 | { 91 | return $this->inCorrectArray; 92 | } 93 | 94 | /** 95 | * @return Money[] 96 | */ 97 | public function getMoneyList(): array 98 | { 99 | return $this->moneyList; 100 | } 101 | 102 | /** 103 | * @return PlusMoney[] 104 | */ 105 | public function getPlusMoneyList(): array 106 | { 107 | return $this->plusMoneyList; 108 | } 109 | 110 | /** 111 | * @return Skill[] 112 | */ 113 | public function getSkillList(): array 114 | { 115 | return $this->skillList; 116 | } 117 | 118 | public function getMoney(): Money 119 | { 120 | return $this->money; 121 | } 122 | 123 | public function getPlusMoney(): PlusMoney 124 | { 125 | return $this->plusMoney; 126 | } 127 | 128 | public function getSkill(): Skill 129 | { 130 | return $this->skill; 131 | } 132 | 133 | public function getExample(): Example 134 | { 135 | return $this->example; 136 | } 137 | 138 | /** 139 | * @return Example[] 140 | */ 141 | public function getExampleList(): array 142 | { 143 | return $this->exampleList; 144 | } 145 | 146 | public function getExample2(): Example2 147 | { 148 | return $this->example2; 149 | } 150 | 151 | public function getExample21(): Example2 152 | { 153 | return $this->example21; 154 | } 155 | 156 | public function getExample3(): Example3 157 | { 158 | return $this->example3; 159 | } 160 | 161 | public function getExample31(): Example3 162 | { 163 | return $this->example31; 164 | } 165 | 166 | public function getExample4(): Example3 167 | { 168 | return $this->example4; 169 | } 170 | 171 | public function getExample41(): Example3 172 | { 173 | return $this->example41; 174 | } 175 | 176 | public function getAllowNull(): ?string 177 | { 178 | return $this->allowNull; 179 | } 180 | 181 | protected function rule(): array 182 | { 183 | return ['name' => ['required'], 'age' => ['required', 'int']]; 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PHP-DataTransferObject-Mapper 2 | 3 | [![code-style](https://github.com/styoo4001/php-dto-mapper/actions/workflows/code-style.yml/badge.svg)](https://github.com/styoo4001/php-dto-mapper/actions/workflows/code-style.yml) 4 | [![run-tests](https://github.com/styoo4001/php-dto-mapper/actions/workflows/run-tests.yml/badge.svg)](https://github.com/styoo4001/php-dto-mapper/actions/workflows/run-tests.yml) 5 | 6 | ## Update 1.10 7 | 8 | ### 1. multi array bind with create Class 9 | 10 | ```php 11 | if $userList = [ 12 | ['user_nm' => 'A' , 'age' => 15], 13 | ['user_nm' => 'B' , 'age' => 16], 14 | ]; 15 | /** @var User[] */ 16 | public array $userList; 17 | 18 | // then get_class($this->userList[0]) === User::class; 19 | 20 | ``` 21 | 22 | ### 2. @separator 23 | 24 | ```php 25 | @separator 26 | // if ?numbers="123,456,789" 27 | /** @var int[] @separator "," */ 28 | public array $numbers; 29 | 30 | // then $numbers = [123,456,789]; 31 | // if /** @var string[] @separator "," */ 32 | // then $numbers = ['123','456','789']; 33 | ``` 34 | 35 | ### 3. @ParamName 36 | 37 | ```php 38 | // if ?number="123" 39 | /** @ParamName("number") or @ParamName(number) or @ParamName('number') */ 40 | public int $no; 41 | 42 | // then $this->no = 123; 43 | ``` 44 | 45 | ### 4. @Column('name=') 46 | 47 | ```php 48 | // if $db = [ 'usr_nm' => 'seungtae.yoo' ]; 49 | 50 | /** @Column('usr_nm') or @Column(anyString = 'user_nm') */ 51 | public string $userName; 52 | 53 | // then $this->userName = "seungtae.yoo"; 54 | ``` 55 | 56 | ## What is PHP-DataTransferObject-Mapper? 57 | 58 | This mapper is useful when converting data in an array into DTO. 59 | 60 | It automatically manages the type when using the type hint in the latest PHP version (7.4 or higher) environment, so that developers do not care about the type. 61 | 62 | External libraries are not required and only operate with PHP APIs. 63 | 64 | If there is a provider layer in the framework, you can apply this mapper to implement it like a Command Object. 65 | 66 | ## How to use 67 | 68 | f you use it simply for data mapping, you only need to use one data transfer mapper.php file. 69 | 70 | If you want to use it like a command object, use the whole thing. 71 | 72 | ```php 73 | 'styoo', 77 | 'age' => '35' 78 | ]; 79 | 80 | class Controller 81 | { 82 | // converting array data into DTO. 83 | public function example() 84 | { 85 | $mapper = new DataTransferObjectMapper(); 86 | /** @var User $user */ 87 | $user = $mapper->mapping($_REQUEST, User::class)->getClass(); 88 | if ($mapper->hasErrors()) throw new Exception(var_export($mapper->getErrors(), true)); 89 | $user->getName() === 'styoo'; 90 | $user->getAge() === 35; 91 | } 92 | 93 | // If there is a provider layer in the framework, 94 | // you can apply this mapper to implement it like a Command Object. 95 | public function index(User $user) 96 | { 97 | if ($user->hasErrors()) throw new Exception(var_export($user->getErrors(), true)); 98 | 99 | $user->getName() === 'styoo'; 100 | $user->getAge() === 35; 101 | } 102 | } 103 | 104 | // Inheritance is only necessary when used as a command object. 105 | class User extends CommandObject 106 | { 107 | private string $name; 108 | private int $age; 109 | 110 | public function getName(): string 111 | { 112 | return $this->name; 113 | } 114 | 115 | public function getAge(): int 116 | { 117 | return $this->age; 118 | } 119 | 120 | protected function rule(): array 121 | { 122 | return ['name' => ['required'], 'age' => ['required', 'int']]; 123 | } 124 | } 125 | ``` 126 | 127 | ## Testing 128 | 129 | ```shell 130 | composer test 131 | ``` 132 | 133 | And you can see below: 134 | 135 | ```shell 136 | > ./vendor/bin/phpunit tests 137 | PHPUnit 11.0.8 by Sebastian Bergmann and contributors. 138 | 139 | Runtime: PHP 8.2.16 140 | Configuration: /Users/cable8mm/Sites/php-dto-mapper/phpunit.xml.dist 141 | 142 | 1111111111php5php71000200020003000150020001111111111\DateTime::__set_state(array( 143 | 'date' => '2021-01-01 15:00:02.000000', 144 | 'timezone_type' => 3, 145 | 'timezone' => 'UTC', 146 | )). 1 / 1 (100%) 147 | 148 | Time: 00:00.007, Memory: 8.00 MB 149 | 150 | OK (1 test, 0 assertions) 151 | ``` 152 | 153 | ## Formatting 154 | 155 | ```shell 156 | composer lint 157 | # Modify all files to comply with the PSR-12. 158 | 159 | composer inspect 160 | # Inspect all files to ensure compliance with PSR-12. 161 | ``` 162 | -------------------------------------------------------------------------------- /src/DataTransferObjectMapper.php: -------------------------------------------------------------------------------- 1 | 17 | * 18 | * @version 1.10 19 | */ 20 | class DataTransferObjectMapper 21 | { 22 | public const CONVERT_SNAKE_TO_CAMEL = 1; 23 | 24 | public const CONVERT_CAMEL_TO_SNAKE = 2; 25 | 26 | public const CONVERT_NONE = 0; 27 | 28 | public const CONVERT_UPPER_TO_LOWER = 3; 29 | 30 | private array $errors; 31 | 32 | private ReflectionProperty $reflectionProperty; 33 | 34 | /** 35 | * @var mixed 36 | */ 37 | private $class; 38 | 39 | private string $classNamespace = ''; 40 | 41 | public function mapping(array $parameters, $className, int $convertType = self::CONVERT_NONE): static 42 | { 43 | $this->errors = []; 44 | 45 | if (gettype($className) === 'string' && ! class_exists($className)) { 46 | throw new Exception("not exists class => {$className}"); 47 | } 48 | if (is_object($className)) { 49 | $this->class = $className; 50 | } else { 51 | $this->class = new $className(); 52 | } 53 | $reflectionClass = new ReflectionClass($this->class); 54 | $this->classNamespace = $reflectionClass->getNamespaceName(); 55 | if ($convertType === self::CONVERT_NONE) { 56 | $convertType = $this->selectKeyConventionConvertType($reflectionClass); 57 | } 58 | $convertedParameters = $this->convertParameterWithConvertType($parameters, $convertType); 59 | $this->setIfClassHasOnlySingleProperty($reflectionClass, $parameters, $convertType); 60 | $this->setPropertyWithConvertedParameters($reflectionClass, $convertedParameters, $convertType); 61 | 62 | return $this; 63 | } 64 | 65 | /** 66 | * @return mixed 67 | */ 68 | public function getClass() 69 | { 70 | return $this->class; 71 | } 72 | 73 | /** 74 | * @return mixed 75 | * 76 | * @throws DataMappingException 77 | */ 78 | public function getClassWithErrorCheck() 79 | { 80 | if ($this->hasErrors()) { 81 | throw new DataMappingException(implode(',', $this->getErrors())); 82 | } 83 | 84 | return $this->class; 85 | } 86 | 87 | private function convertSnakeToCamel(string $str): string 88 | { 89 | $str = str_replace('_', '', ucwords($str, '_')); 90 | $str[0] = strtolower($str[0]); 91 | 92 | return (string) $str; 93 | } 94 | 95 | private function convertCamelToSnake(string $str): string 96 | { 97 | $pattern = '!([A-Z][A-Z0-9]*(?=$|[A-Z][a-z0-9])|[A-Za-z][a-z0-9]+)!'; 98 | preg_match_all($pattern, $str, $matches); 99 | $ret = $matches[0]; 100 | foreach ($ret as &$match) { 101 | $match = $match == strtoupper($match) ? 102 | strtolower($match) : 103 | lcfirst($match); 104 | } 105 | 106 | return implode('_', $ret); 107 | } 108 | 109 | /** 110 | * @throws Exception 111 | */ 112 | private function setProperty($value, int $convertType = self::CONVERT_NONE): void 113 | { 114 | try { 115 | //property 키로 $parameter값이 존재하면 타입캐스팅 하여 저장 116 | if (is_null($value)) { 117 | //null인 경우 property에 기본값이 지정되어있지 않거나, 또는 nullable 할때만 set 118 | if (! $this->reflectionProperty->isInitialized($this->class) || 119 | ($this->reflectionProperty->hasType() && $this->reflectionProperty->getType()->allowsNull())) { 120 | $this->setValue($value); 121 | 122 | return; 123 | } 124 | 125 | return; 126 | } 127 | // 속성에 타입이 없으면 128 | if (! $this->reflectionProperty->hasType()) { 129 | $this->setValue($value); 130 | 131 | return; 132 | } 133 | 134 | $propertyType = $this->reflectionProperty->getType(); 135 | if (! $propertyType instanceof ReflectionNamedType) { 136 | return; 137 | } 138 | switch ($propertyType->getName()) { 139 | case 'string': 140 | case 'bool': 141 | $this->setValue($value); 142 | break; 143 | case 'int': 144 | case 'float': 145 | $value = $this->escapeStringForNumberConverting($value); 146 | if ($value === '' && $this->reflectionProperty->isInitialized($this->class)) { 147 | break; 148 | } 149 | $this->setValue($value); 150 | break; 151 | case 'array': 152 | $this->setValue($this->makeValueInArrayType($value, $convertType)); 153 | break; 154 | default: 155 | //dto property 타입이 객체이면, 156 | $class = $propertyType->getName(); 157 | if (! class_exists($class)) { 158 | //error 존재하지 않는 클래스 159 | $this->errors[] = "{$class} is not exists"; 160 | break; 161 | } 162 | $this->setValue($this->checkAllPropertyInit($this->makeValueInObjectType($class, $value, $convertType))); 163 | } 164 | } catch (Throwable $e) { 165 | $this->errors[$this->reflectionProperty->getName()] = get_class($this->class)."->{$this->reflectionProperty->getName()} property error occurred. getMessage() => {$e->getMessage()}"; 166 | } 167 | } 168 | 169 | private function setValue($value) 170 | { 171 | try { 172 | $this->reflectionProperty->setValue($this->class, $value); 173 | } catch (Throwable $e) { 174 | $this->errors[$this->reflectionProperty->getName()] = get_class($this->class)."->{$this->reflectionProperty->getName()} set fail => {$e->getMessage()}"; 175 | } 176 | } 177 | 178 | /** 179 | * @return mixed 180 | */ 181 | private function checkAllPropertyInit($instancedClass) 182 | { 183 | if (! is_object($instancedClass)) { 184 | return $instancedClass; 185 | } 186 | try { 187 | foreach ((new ReflectionClass($instancedClass))->getProperties() as $reflectionProperty) { 188 | $reflectionProperty->setAccessible(true); 189 | if (! $reflectionProperty->isInitialized($instancedClass)) { 190 | $this->errors[$reflectionProperty->getName()] = get_class($instancedClass)."->{$reflectionProperty->getName()} is not initialized"; 191 | } 192 | } 193 | } catch (ReflectionException $e) { 194 | $this->errors[] = get_class($instancedClass)."->{$e->getMessage()}"; 195 | } 196 | 197 | return $instancedClass; 198 | } 199 | 200 | public function hasErrors(): bool 201 | { 202 | return ! empty($this->errors); 203 | } 204 | 205 | public function getErrors(): array 206 | { 207 | return $this->errors; 208 | } 209 | 210 | private function escapeStringForNumberConverting($value) 211 | { 212 | return str_replace(',', '', $value); 213 | } 214 | 215 | private function selectKeyConventionConvertType(ReflectionClass $reflectionClass): int 216 | { 217 | $convertType = self::CONVERT_NONE; 218 | if (! empty($reflectionClass->getDocComment() && 219 | preg_match('/@namingConvention\s+([^\s]+)/', $reflectionClass->getDocComment(), $match))) { 220 | [, $type] = $match; 221 | switch (strtoupper(trim($type))) { 222 | case 'CAMEL': 223 | $convertType = self::CONVERT_SNAKE_TO_CAMEL; 224 | break; 225 | case 'SNAKE': 226 | $convertType = self::CONVERT_CAMEL_TO_SNAKE; 227 | break; 228 | case 'LOWER': 229 | $convertType = self::CONVERT_UPPER_TO_LOWER; 230 | break; 231 | default: 232 | $convertType = self::CONVERT_NONE; 233 | } 234 | } 235 | 236 | return $convertType; 237 | } 238 | 239 | private function convertParameterWithConvertType(array $parameters, int $convertType): array 240 | { 241 | $convertedParameters = []; 242 | foreach ($parameters as $key => $value) { 243 | $key = trim($key); 244 | if ($convertType === self::CONVERT_SNAKE_TO_CAMEL) { 245 | $convertedParameters[$this->convertSnakeToCamel($key)] = $value; 246 | } elseif ($convertType === self::CONVERT_CAMEL_TO_SNAKE) { 247 | $convertedParameters[$this->convertCamelToSnake($key)] = $value; 248 | } elseif ($convertType === self::CONVERT_UPPER_TO_LOWER) { 249 | $convertedParameters[strtolower($key)] = $value; 250 | } else { 251 | $convertedParameters[$key] = $value; 252 | } 253 | } 254 | 255 | return $convertedParameters; 256 | } 257 | 258 | /** 259 | * @throws Exception 260 | */ 261 | private function setIfClassHasOnlySingleProperty(ReflectionClass $reflectionClass, array $parameters, int $convertType): void 262 | { 263 | /** @var ReflectionProperty[] $childClassProperties */ 264 | $childClassProperties = []; 265 | foreach ($reflectionClass->getProperties() as $property) { 266 | if ($property->getDeclaringClass()->getName() === get_class($this->class)) { 267 | $childClassProperties[] = $property; 268 | } 269 | } 270 | 271 | if (count($parameters) === 1 && count($childClassProperties) === 1 && array_keys($parameters)[0] === 0) { 272 | $this->reflectionProperty = $childClassProperties[0]; 273 | $this->reflectionProperty->setAccessible(true); 274 | $this->setProperty($parameters[0], $convertType); 275 | } elseif (count($childClassProperties) === 1 && ! $this->isAssoc($parameters)) { 276 | $this->reflectionProperty = $childClassProperties[0]; 277 | $this->reflectionProperty->setAccessible(true); 278 | $this->setProperty($parameters, $convertType); 279 | } 280 | } 281 | 282 | /** 283 | * @throws Exception 284 | */ 285 | private function setPropertyWithConvertedParameters(ReflectionClass $reflectionClass, array $convertedParameters, int $convertType): void 286 | { 287 | foreach ($reflectionClass->getProperties() as $reflectionProperty) { 288 | $this->reflectionProperty = $reflectionProperty; 289 | $propertyName = $this->getPropertyName($this->reflectionProperty); 290 | $reflectionProperty->setAccessible(true); 291 | 292 | if ($reflectionProperty->hasType() && $this->reflectionProperty->getType()->allowsNull()) { 293 | $this->setProperty(null); 294 | } 295 | 296 | if (array_key_exists($propertyName, $convertedParameters)) { 297 | $this->setProperty($convertedParameters[$propertyName], $convertType); 298 | } 299 | if (! $reflectionProperty->isInitialized($this->class)) { 300 | $this->errors[$reflectionProperty->getName()] = get_class($this->class)."->{$reflectionProperty->getName()} is not initialized"; 301 | } 302 | } 303 | 304 | } 305 | 306 | private function getPropertyName(ReflectionProperty $reflectionProperty): string 307 | { 308 | $this->reflectionProperty = $reflectionProperty; 309 | $paramName = $this->getPropertyKeyName('@ParamName'); 310 | if (! empty($paramName)) { 311 | return $paramName; 312 | } 313 | $columnName = $this->getPropertyKeyName('@Column'); 314 | if (! empty($columnName)) { 315 | return $columnName; 316 | } 317 | 318 | return $reflectionProperty->getName(); 319 | } 320 | 321 | private function getPropertyDocComment(string $docCommentKey): string 322 | { 323 | $match = null; 324 | $docComment = ''; 325 | if (preg_match("/{$docCommentKey}\s+([^\s]+)/", $this->reflectionProperty->getDocComment() ?: '', $match)) { 326 | [, $type] = $match; 327 | $docComment = strlen(trim($type)) > 0 ? trim($type) : ''; 328 | } 329 | 330 | return $docComment; 331 | } 332 | 333 | private function getPropertyKeyName(string $docCommentKey): string 334 | { 335 | $match = null; 336 | if (preg_match('/(?<=\\'.$docCommentKey."\\()(.*?)(?=\))/", $this->reflectionProperty->getDocComment() ?: '', $match)) { 337 | [$column] = $match; 338 | $column = str_replace(["'", '"'], '', $column); 339 | $column = explode('=', $column); 340 | if (count($column) === 2) { 341 | return trim($column[1]); 342 | } 343 | 344 | if (count($column) === 1) { 345 | return trim($column[0]); 346 | } 347 | } 348 | 349 | return ''; 350 | } 351 | 352 | /** 353 | * @throws Exception 354 | */ 355 | private function makeValueInArrayType($value, int $convertType): array 356 | { 357 | //property 주석에 @namespace가 있으면 클래스로 인스턴스화 하여 할당한다. 358 | $propertyDocNamespace = $this->getPropertyDocComment('@namespace'); 359 | //@namespace 주석에 classPath가 적혀있지 않거나, 존재하지 않는 클래스면 value를 직접 할당함. 360 | $classType = str_replace('[]', '', $this->getPropertyDocComment('@var')); 361 | if (empty($propertyDocNamespace) && $this->classExistsOnDocument($classType)) { 362 | $propertyDocNamespace = $this->classNamespace.'\\'.$classType; 363 | } 364 | 365 | if (empty($propertyDocNamespace)) { 366 | if (is_array($value)) { 367 | $propertyDocVar = $this->getPropertyDocComment('@var'); 368 | if (! empty($propertyDocVar)) { 369 | $scalarType = str_replace('[]', '', $propertyDocVar); 370 | if (! $this->isAssoc($value)) { 371 | return array_map(function ($el) use ($scalarType) { 372 | return $this->forceCasting($scalarType, $el); 373 | }, $value); 374 | } 375 | } 376 | 377 | return $value; 378 | } 379 | $propertyDocVar = $this->getPropertyDocComment('@var'); 380 | if (! empty($propertyDocVar)) { 381 | $scalarType = str_replace('[]', '', $propertyDocVar); 382 | if (is_scalar($value)) { 383 | if (is_string($value)) { 384 | $valueArray = $this->explodeWithSeparator($value); 385 | 386 | return array_map(fn ($value) => $this->forceCasting($scalarType, $value), $valueArray); 387 | } 388 | $value = $this->forceCasting($scalarType, $value); 389 | } 390 | } 391 | 392 | return [$value]; 393 | } 394 | if (! class_exists($propertyDocNamespace)) { 395 | $this->errors[] = "{$this->reflectionProperty->getName()} documentComment class is not exist"; 396 | 397 | return []; 398 | } 399 | 400 | if (! is_iterable($value)) { 401 | $propertyDocVar = $this->getPropertyDocComment('@var'); 402 | if (! empty($propertyDocVar)) { 403 | $scalarType = str_replace('[]', '', $propertyDocVar); 404 | if (is_scalar($value)) { 405 | if (is_string($value)) { 406 | $valueArray = $this->explodeWithSeparator($value); 407 | 408 | return array_map(fn ($value) => $this->forceCasting($scalarType, $value), $valueArray); 409 | } 410 | $value = $this->forceCasting($scalarType, $value); 411 | } 412 | } 413 | 414 | return [$value]; 415 | } 416 | 417 | /** @disregard P1013 */ 418 | return array_map(function ($data) use ($propertyDocNamespace, $convertType) { 419 | return $this->checkAllPropertyInit($this->makeValueInObjectType($propertyDocNamespace, $data, $convertType)); 420 | }, $value); 421 | } 422 | 423 | /** 424 | * @return mixed|void 425 | * 426 | * @throws Exception 427 | */ 428 | private function makeValueInObjectType($class, $value, int $convertType) 429 | { 430 | // value가 객체이면 그대로 set 431 | if (is_object($value) && get_class($value) === $class) { 432 | return $value; 433 | } 434 | 435 | // value가 array이면 property 타입으로 인스턴스 436 | if (is_array($value)) { 437 | $reflectionClass = new ReflectionClass($class); 438 | if ($this->isNotUsingConstructor($reflectionClass)) { 439 | return $this->recursiveMapping($value, new $class(), $convertType); 440 | 441 | } 442 | if ($this->constructorHasOnlySingleRequiredArgument($reflectionClass)) { 443 | $reflectionParameter = $reflectionClass->getConstructor()->getParameters()[0]; 444 | if (! $reflectionParameter->hasType()) { 445 | return new $class($value); 446 | } 447 | $propertyType = $reflectionParameter->getType(); 448 | if ($propertyType instanceof ReflectionNamedType && $propertyType->getName() === 'array') { 449 | return new $class($value); 450 | } 451 | } 452 | 453 | if ($this->constructorHasManyArgument($reflectionClass)) { 454 | if ($this->isAssoc($value)) { 455 | $args = []; 456 | foreach ($reflectionClass->getConstructor()->getParameters() as $parameter) { 457 | if ($parameter->isDefaultValueAvailable()) { 458 | if (array_key_exists($parameter->getName(), $value)) { 459 | $args[] = $value[$parameter->getName()]; 460 | } else { 461 | $args[] = $parameter->getDefaultValue(); 462 | } 463 | } else { 464 | $args[] = $value[$parameter->getName()] ?? null; 465 | } 466 | } 467 | 468 | } else { 469 | $args = []; 470 | foreach ($reflectionClass->getConstructor()->getParameters() as $index => $parameter) { 471 | if ($parameter->isDefaultValueAvailable()) { 472 | if (array_key_exists($index, $value)) { 473 | $args[] = $value[$index]; 474 | } else { 475 | $args[] = $parameter->getDefaultValue(); 476 | } 477 | } else { 478 | $args[] = $value[$index] ?? null; 479 | } 480 | } 481 | } 482 | 483 | switch ($reflectionClass->getConstructor()->getNumberOfParameters()) { 484 | case 2: 485 | $instanceClass = new $class($args[0], $args[1]); 486 | break; 487 | case 3: 488 | $instanceClass = new $class($args[0], $args[1], $args[2]); 489 | break; 490 | case 4: 491 | $instanceClass = new $class($args[0], $args[1], $args[3], $args[4]); 492 | break; 493 | case 5: 494 | $instanceClass = new $class($args[0], $args[1], $args[3], $args[4], $args[5]); 495 | break; 496 | case 6: 497 | $instanceClass = new $class($args[0], $args[1], $args[3], $args[4], $args[5], $args[6]); 498 | break; 499 | case 7: 500 | $instanceClass = new $class($args[0], $args[1], $args[3], $args[4], $args[5], $args[6], $args[7]); 501 | break; 502 | case 8: 503 | $instanceClass = new $class($args[0], $args[1], $args[3], $args[4], $args[5], $args[6], $args[7], $args[8]); 504 | break; 505 | case 9: 506 | $instanceClass = new $class($args[0], $args[1], $args[3], $args[4], $args[5], $args[6], $args[7], $args[8], $args[9]); 507 | break; 508 | case 10: 509 | $instanceClass = new $class($args[0], $args[1], $args[3], $args[4], $args[5], $args[6], $args[7], $args[8], $args[9], $args[10]); 510 | break; 511 | default: 512 | throw new Exception('Auto constructor argument value is only possible up to 10'); 513 | } 514 | 515 | return $instanceClass; 516 | } 517 | } 518 | if ($this->isDefaultType($value)) { 519 | // value 가 일반 기본값이면 객체 생성자 arg 로 생성 ex) value object 520 | $reflectionClass = new ReflectionClass($class); 521 | if ($this->isNotUsingConstructor($reflectionClass)) { 522 | return $this->recursiveMapping([$value], new $class(), $convertType); 523 | } 524 | 525 | if ($this->constructorHasOnlySingleRequiredArgument($reflectionClass)) { 526 | $reflectionParameter = $reflectionClass->getConstructor()->getParameters()[0]; 527 | 528 | return new $class($this->forceCastingByDefaultType($reflectionParameter, $value)); 529 | } 530 | if ($this->constructorArgumentsThatHaveAllDefaultValue($reflectionClass)) { 531 | $reflectionParameter = $reflectionClass->getConstructor()->getParameters()[0]; 532 | 533 | return new $class($this->forceCastingByDefaultType($reflectionParameter, $value)); 534 | } 535 | } 536 | $notDefinedType = gettype($value); 537 | $this->errors[] = "{$this->reflectionProperty->getName()} set fail, value is not define type ({$notDefinedType}))"; 538 | 539 | return $value; 540 | } 541 | 542 | private function forceCastingByDefaultType(ReflectionParameter $reflectionParameter, $value) 543 | { 544 | if (! $reflectionParameter->hasType()) { 545 | return $value; 546 | } 547 | $propertyType = $reflectionParameter->getType(); 548 | if (! $propertyType instanceof ReflectionNamedType) { 549 | return $value; 550 | } 551 | if ($propertyType->getName() === gettype($value)) { 552 | return $value; 553 | } 554 | 555 | return $this->forceCasting($propertyType->getName(), $value); 556 | } 557 | 558 | private function forceCasting(string $name, $value) 559 | { 560 | switch ($name) { 561 | case 'string': 562 | $value = (string) $value; 563 | break; 564 | case 'bool': 565 | $value = (bool) $value; 566 | break; 567 | case 'int': 568 | $value = (int) $this->escapeStringForNumberConverting($value); 569 | break; 570 | case 'float': 571 | $value = (float) $this->escapeStringForNumberConverting($value); 572 | break; 573 | } 574 | 575 | return $value; 576 | } 577 | 578 | /** 579 | * @return mixed 580 | */ 581 | private function recursiveMapping($value, $class, int $convertType) 582 | { 583 | $mapper = new self(); 584 | $mapper->mapping($value, $class, $convertType); 585 | if ($mapper->hasErrors()) { 586 | $this->errors = array_merge($this->errors, $mapper->getErrors()); 587 | } 588 | 589 | return $mapper->getClass(); 590 | } 591 | 592 | private function isNotUsingConstructor(ReflectionClass $reflectionClass): bool 593 | { 594 | return is_null($reflectionClass->getConstructor()) || $reflectionClass->getConstructor()->getNumberOfParameters() === 0; 595 | } 596 | 597 | private function constructorHasOnlySingleArgument(ReflectionClass $reflectionClass): bool 598 | { 599 | return ! is_null($reflectionClass->getConstructor()) && $reflectionClass->getConstructor()->getNumberOfParameters() === 1; 600 | } 601 | 602 | private function constructorHasOnlySingleRequiredArgument(ReflectionClass $reflectionClass): bool 603 | { 604 | return ! is_null($reflectionClass->getConstructor()) && $reflectionClass->getConstructor()->getNumberOfRequiredParameters() === 1; 605 | } 606 | 607 | private function constructorArgumentsThatHaveAllDefaultValue(ReflectionClass $reflectionClass): bool 608 | { 609 | return ! is_null($reflectionClass->getConstructor()) && $reflectionClass->getConstructor()->getNumberOfParameters() > 0 && $reflectionClass->getConstructor()->getNumberOfRequiredParameters() === 0; 610 | } 611 | 612 | private function constructorHasManyArgument(ReflectionClass $reflectionClass): bool 613 | { 614 | return ! is_null($reflectionClass->getConstructor()) && $reflectionClass->getConstructor()->getNumberOfParameters() > 1; 615 | } 616 | 617 | private function isDefaultType($value): bool 618 | { 619 | return is_int($value) || is_float($value) || is_string($value) || is_bool($value); 620 | } 621 | 622 | private function isAssoc(array $arr): bool 623 | { 624 | if ($arr === []) { 625 | return false; 626 | } 627 | 628 | return array_keys($arr) !== range(0, count($arr) - 1); 629 | } 630 | 631 | private function explodeWithSeparator(string $value): array 632 | { 633 | if (is_string($value)) { 634 | $separator = $this->getPropertyDocComment('@separator'); 635 | $separator = str_replace('"', '', $separator); 636 | $separator = str_replace("'", '', $separator); 637 | if (! empty($separator)) { 638 | return explode($separator, $value); 639 | } 640 | } 641 | 642 | return [$value]; 643 | } 644 | 645 | private function classExistsOnDocument(string $classType): bool 646 | { 647 | if (empty($classType)) { 648 | return false; 649 | } 650 | if (in_array($classType, ['int', 'string', 'float', 'bool'])) { 651 | return false; 652 | } 653 | 654 | return class_exists($this->classNamespace.'\\'.$classType); 655 | } 656 | } 657 | --------------------------------------------------------------------------------