├── 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 | [](https://github.com/styoo4001/php-dto-mapper/actions/workflows/code-style.yml)
4 | [](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 |
--------------------------------------------------------------------------------