Skip to content

Commit e1bcfa3

Browse files
committed
[core] v4.5.0 - prepareInput() preprocessing
1 parent c4584d6 commit e1bcfa3

12 files changed

Lines changed: 558 additions & 13 deletions

‎CHANGELOG.md‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,31 @@
11
# CHANGELOG
22

3+
## [v4.5.0] - 2026-05-28
4+
5+
### Added
6+
7+
- **`prepareInput()` — input preprocessing before hydration.** Declare `protected static prepareInput(array $data): array` in any subclass to normalize, transform, or derive input values. Runs after `defaultValues()` and `#[Defaults]` are merged, before type resolution and hydration.
8+
9+
```php
10+
readonly class CreateUserDTO extends DataTransferObject
11+
{
12+
public string $email;
13+
#[Defaults('member')]
14+
public string $role;
15+
16+
protected static function prepareInput(array $data): array
17+
{
18+
return ['email' => strtolower(trim($data['email']))];
19+
}
20+
}
21+
```
22+
23+
Key behaviors:
24+
- `$data` already contains merged defaults at call time — both `defaultValues()` and `#[Defaults]` values are visible and transformable.
25+
- Only keys already present in `$data` are written back (`array_intersect_key` guard prevents phantom-property injection).
26+
- Each class in the inheritance chain that declares its own `prepareInput()` runs in order from root to concrete class, so parent transforms apply before child transforms.
27+
- `with()` does not invoke `prepareInput()` — preparation applies only to untrusted external input; `with()` callers are trusted code.
28+
329
## [v4.4.0] - 2026-05-12
430

531
### Added

‎README.md‎

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,41 @@ $signUp = SignUpUsersDTO::fromArray([
224224
]);
225225
```
226226

227+
---
228+
229+
## Life Cycle
230+
231+
```mermaid
232+
%%{init: {'theme': 'base', 'themeVariables': {'background': '#0d1117', 'mainBkg': '#0d1117'}}}%%
233+
flowchart TD
234+
classDef input fill:#0d1117,color:#79c0ff,stroke:#388bfd,stroke-width:2px
235+
classDef constructor fill:#0d1117,color:#ffa657,stroke:#d29922,stroke-width:2px
236+
classDef defaults fill:#0d1117,color:#d2a8ff,stroke:#8b949e,stroke-width:1px,stroke-dasharray:4
237+
classDef prepare fill:#0d1117,color:#d2a8ff,stroke:#8b949e,stroke-width:1px,stroke-dasharray:4
238+
classDef validate fill:#0d1117,color:#56d364,stroke:#3fb950,stroke-width:1px,stroke-dasharray:4
239+
classDef output fill:#0d1117,color:#ff7b72,stroke:#f85149,stroke-width:2px
240+
241+
R([raw data]):::input
242+
243+
R -->|array / json| F1
244+
R -->|scalar only| F2
245+
246+
F1["fromArray() / fromJson()\n― DTO / VO ―"]:::constructor
247+
F2["from()\n― SVO ―"]:::constructor
248+
249+
F1 & F2 --> DEF1
250+
251+
DEF1["#[Defaults]"]:::defaults
252+
DEF1 --> DEF2
253+
DEF2["defaultValues()"]:::defaults
254+
255+
DEF2 --> P["prepareInput()"]:::prepare
256+
257+
P -->|DTO| I([instance]):::output
258+
P -->|VO / SVO| VAL["validate()"]:::defaults
259+
VAL --> I
260+
```
261+
227262
> 🔗 Want a quick try? [JSON to ImmutableBase Converter](https://json-to-immutablebase-object-converter.reallife-kip.com) lets you paste JSON and generate IB classes instantly!
228263
229264
---
@@ -465,6 +500,69 @@ Config::fromArray(['theme' => 'light']); // theme = 'light' (explicit value →
465500

466501
---
467502

503+
## Preprocessing
504+
505+
Normalize, transform, or derive input values before hydration. Runs after all defaults are merged, before type resolution.
506+
507+
```php
508+
readonly class CreateUserDTO extends DataTransferObject
509+
{
510+
public string $email;
511+
#[Defaults('member')]
512+
public string $role;
513+
514+
protected static function prepareInput(array $data): array
515+
{
516+
return ['email' => strtolower(trim($data['email']))];
517+
}
518+
}
519+
520+
CreateUserDTO::fromArray(['email' => ' BILL@EXAMPLE.COM ']);
521+
// email = 'bill@example.com', role = 'member'
522+
```
523+
524+
### Key Behaviors
525+
526+
**`$data` already contains defaults.** Both `defaultValues()` and `#[Defaults]` values are merged into `$data` before `prepareInput()` is called — you can read and transform them.
527+
528+
**Phantom-key injection is blocked.** Only keys already present in `$data` are written back. Returning a new key from `prepareInput()` is silently ignored.
529+
530+
```php
531+
protected static function prepareInput(array $data): array
532+
{
533+
return [
534+
'email' => strtolower($data['email']),
535+
'phantom' => 'injected', // ignored — 'phantom' is not a property
536+
];
537+
}
538+
```
539+
540+
**Inheritance chain stacks.** Each class in the hierarchy that declares its own `prepareInput()` runs in order from root to concrete. The parent's output feeds into the child's input.
541+
542+
```php
543+
readonly class ParentDTO extends DataTransferObject
544+
{
545+
public string $name;
546+
547+
protected static function prepareInput(array $data): array
548+
{
549+
return ['name' => trim($data['name'])];
550+
}
551+
}
552+
553+
readonly class ChildDTO extends ParentDTO
554+
{
555+
protected static function prepareInput(array $data): array
556+
{
557+
return ['name' => strtoupper($data['name'])]; // runs after parent trim
558+
}
559+
}
560+
```
561+
562+
**`with()` does not run `prepareInput()`.** `prepareInput()` is designed to sanitize untrusted external input (HTTP, CSV, API payloads). `with()` is a programmatic mutation by the developer, who is already the trusted caller — preparation is not applicable.
563+
564+
---
565+
468566
## Attributes
469567

470568
### `#[Defaults]` - Property Default Value

‎README_TW.md‎

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,41 @@ $signUp = SignUpUsersDTO::fromArray([
224224
]);
225225
```
226226

227+
---
228+
229+
## 生命週期
230+
231+
```mermaid
232+
%%{init: {'theme': 'base', 'themeVariables': {'background': '#0d1117', 'mainBkg': '#0d1117'}}}%%
233+
flowchart TD
234+
classDef input fill:#0d1117,color:#79c0ff,stroke:#388bfd,stroke-width:2px
235+
classDef constructor fill:#0d1117,color:#ffa657,stroke:#d29922,stroke-width:2px
236+
classDef defaults fill:#0d1117,color:#d2a8ff,stroke:#8b949e,stroke-width:1px,stroke-dasharray:4
237+
classDef prepare fill:#0d1117,color:#d2a8ff,stroke:#8b949e,stroke-width:1px,stroke-dasharray:4
238+
classDef validate fill:#0d1117,color:#56d364,stroke:#3fb950,stroke-width:1px,stroke-dasharray:4
239+
classDef output fill:#0d1117,color:#ff7b72,stroke:#f85149,stroke-width:2px
240+
241+
R([raw data]):::input
242+
243+
R -->|array / json| F1
244+
R -->|scalar only| F2
245+
246+
F1["fromArray() / fromJson()\n― DTO / VO ―"]:::constructor
247+
F2["from()\n― SVO ―"]:::constructor
248+
249+
F1 & F2 --> DEF1
250+
251+
DEF1["#[Defaults]"]:::defaults
252+
DEF1 --> DEF2
253+
DEF2["defaultValues()"]:::defaults
254+
255+
DEF2 --> P["prepareInput()"]:::prepare
256+
257+
P -->|DTO| I([instance]):::output
258+
P -->|VO / SVO| VAL["validate()"]:::defaults
259+
VAL --> I
260+
```
261+
227262
> 🔗 想快速嘗試?[JSON to ImmutableBase Converter](https://json-to-immutablebase-object-converter.reallife-kip.com) 讓你貼上 JSON 就能快速產出 ImmutableBase 物件!
228263
229264
---
@@ -465,6 +500,69 @@ Config::fromArray(['theme' => 'light']); // theme = 'light' (明確傳入值
465500

466501
---
467502

503+
## 預處理
504+
505+
可在 hydration 之前對輸入值進行正規化、轉換或衍生。執行時機:所有 defaults 合併完成後、型別解析之前。
506+
507+
```php
508+
readonly class CreateUserDTO extends DataTransferObject
509+
{
510+
public string $email;
511+
#[Defaults('member')]
512+
public string $role;
513+
514+
protected static function prepareInput(array $data): array
515+
{
516+
return ['email' => strtolower(trim($data['email']))];
517+
}
518+
}
519+
520+
CreateUserDTO::fromArray(['email' => ' BILL@EXAMPLE.COM ']);
521+
// email = 'bill@example.com', role = 'member'
522+
```
523+
524+
### 行為說明
525+
526+
**`$data` 已包含 defaults。** `defaultValues()` 與 `#[Defaults]` 的值在 `prepareInput()` 呼叫前已合併進 `$data`,可直接讀取並轉換。
527+
528+
**幽靈 key 注入會被阻擋。** 只有 `$data` 中已存在的 key 才會被回寫,回傳新 key 會被靜默忽略。
529+
530+
```php
531+
protected static function prepareInput(array $data): array
532+
{
533+
return [
534+
'email' => strtolower($data['email']),
535+
'phantom' => 'injected', // 被忽略 —— 'phantom' 不是屬性
536+
];
537+
}
538+
```
539+
540+
**繼承鏈依序疊加。** 每個自行宣告 `prepareInput()` 的類別都會執行,順序從 root 到 concrete,父層的輸出作為子層的輸入。
541+
542+
```php
543+
readonly class ParentDTO extends DataTransferObject
544+
{
545+
public string $name;
546+
547+
protected static function prepareInput(array $data): array
548+
{
549+
return ['name' => trim($data['name'])];
550+
}
551+
}
552+
553+
readonly class ChildDTO extends ParentDTO
554+
{
555+
protected static function prepareInput(array $data): array
556+
{
557+
return ['name' => strtoupper($data['name'])]; // 在父層 trim 之後執行
558+
}
559+
}
560+
```
561+
562+
**`with()` 不執行 `prepareInput()`。** `prepareInput()` 的設計情境是清洗不可信的外部輸入(HTTP、CSV、API payload)。`with()` 是開發者在程式碼中主動操作物件,呼叫者本身就是受信任的一方,「事前準備」的語意在此不適用。
563+
564+
---
565+
468566
## Attributes
469567

470568
### `#[Defaults]` - 屬性預設值

‎src/ImmutableBase.php‎

Lines changed: 32 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -136,8 +136,7 @@ public static function &state(): array
136136
protected function __construct(array $data = [])
137137
{
138138
self::executeSafely(function ($static, &$errorPath) use ($data) {
139-
$s = &self::state();
140-
$defaults = static::defaultValues();
139+
$s = &self::state();
141140
if (!isset($s['properties'][$static])) {
142141
$this::buildPropertyInheritanceChain($this);
143142
}
@@ -154,7 +153,16 @@ protected function __construct(array $data = [])
154153
) {
155154
throw new StrictViolationException($class['name'], $redundant);
156155
}
157-
$class['hydrator']($this, self::resolvePropertyData($data, $class['types'], $defaults, $errorPath));
156+
$compiled = array_filter(array_map(fn($t) => $t['defaults'] ?? null, $class['types']), fn($v) => $v !== null);
157+
$data = array_merge($compiled, $data);
158+
foreach ($class['classTreeReversed'] as $classname) {
159+
$ref = $s['properties'][$classname] ?? null;
160+
if ($ref === null || !($ref['hasPrepareInput'] ?? false)) {
161+
continue;
162+
}
163+
$data = array_merge($data, array_intersect_key($classname::prepareInput($data), $data));
164+
}
165+
$class['hydrator']($this, self::resolvePropertyData($data, $class['types'], $errorPath));
158166
});
159167
}
160168
/**
@@ -225,20 +233,15 @@ final protected static function buildPropertyInheritanceChain(self $object): arr
225233
*
226234
* @param array<string, mixed> $data Input array, mutated in place when a default is injected.
227235
* @param array<string, Type> $types Compiled property type metadata from scanProperties().
228-
* @param array<string, mixed> $defaults Default values returned by defaultValues().
229236
* @param string|null $errorPath Reference updated to the current property name for error context.
230237
* @return array<string, mixed> Resolved property values keyed by property name.
231238
*/
232-
private static function resolvePropertyData(array &$data, array $types, array $defaults, ? string &$errorPath) : array
239+
private static function resolvePropertyData(array &$data, array $types, ?string &$errorPath): array
233240
{
234241
foreach ($types as $type) {
235242
$name = $errorPath = $type['propertyName'];
236243
if (!\array_key_exists($name, $data)) {
237-
$data[$name] = $type['defaults'] ?? match (true) {
238-
\array_key_exists($name, $defaults) => $defaults[$name],
239-
isset($type['propertyRef']) => self::getAttributeArgument($type['propertyRef'], Defaults::class),
240-
default => null
241-
};
244+
$data[$name] = $type['defaults'] ?? (isset($type['propertyRef']) ? self::getAttributeArgument($type['propertyRef'], Defaults::class) : null);
242245
}
243246
match (true) {
244247
!isset($data[$name]) && !$type['allowsNull'] => throw new RequiredValueException($name),
@@ -294,9 +297,10 @@ private static function scanProperties(ReflectionClass $ref, array $flags): arra
294297
throw new InvalidSpecException($classname);
295298
}
296299
}
297-
$hasValidate = $isDTO ? false : $ref->hasMethod('validate');
298-
$classTree = [$classname => $classname] + class_parents($classname);
299-
$prop = [
300+
$hasValidate = $isDTO ? false : $ref->hasMethod('validate');
301+
$hasPrepareInput = $ref->hasMethod('prepareInput') && $ref->getMethod('prepareInput')->getDeclaringClass()->name === $classname;
302+
$classTree = [$classname => $classname] + class_parents($classname);
303+
$prop = [
300304
'ref' => $ref,
301305
'name' => $classname,
302306
'isStrict' => $ref->getAttributes(Strict::class) !== [],
@@ -307,6 +311,7 @@ private static function scanProperties(ReflectionClass $ref, array $flags): arra
307311
'validateFromSelf' => $ref->getAttributes(ValidateFromSelf::class) !== [],
308312
'skipOnNull' => $ref->getAttributes(SkipOnNull::class) !== [],
309313
'hasValidate' => $hasValidate,
314+
'hasPrepareInput' => $hasPrepareInput,
310315
'validateMethod' => $hasValidate ? $ref->getMethod('validate') : false,
311316
'spec' => $spec ?? null,
312317
'classTree' => $classTree,
@@ -1347,6 +1352,20 @@ final public function with(string | array | object $data, string $separator = '.
13471352
*
13481353
* @return array<property-string, mixed>
13491354
*/
1355+
/**
1356+
* Preprocessing step for input normalization before property resolution.
1357+
* Called after defaults are merged and key remapping (#[InputKeyTo]) is applied,
1358+
* before type resolution and hydration. Declare in subclasses to normalize values,
1359+
* derive fields, or inject context. Only keys already present in $data are written back.
1360+
*
1361+
* @param array<string, mixed> $data Merged input data (includes defaults)
1362+
* @return array<string, mixed>
1363+
*/
1364+
protected static function prepareInput(array $data): array
1365+
{
1366+
return []; // @codeCoverageIgnore
1367+
}
1368+
13501369
public static function defaultValues(): array
13511370
{
13521371
return [];
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\DataTransferObjects\PrepareInput;
6+
7+
use ReallifeKip\ImmutableBase\Objects\DataTransferObject;
8+
9+
readonly class PrepareInputBasicDTO extends DataTransferObject
10+
{
11+
public string $email;
12+
public string $name;
13+
14+
protected static function prepareInput(array $data): array
15+
{
16+
return ['email' => strtolower(trim($data['email']))];
17+
}
18+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\DataTransferObjects\PrepareInput;
6+
7+
readonly class PrepareInputChildDTO extends PrepareInputParentDTO
8+
{
9+
public string $b;
10+
11+
protected static function prepareInput(array $data): array
12+
{
13+
return ['a' => $data['a'] . '(C)'];
14+
}
15+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\DataTransferObjects\PrepareInput;
6+
7+
readonly class PrepareInputGrandChildDTO extends PrepareInputChildDTO
8+
{
9+
public string $c;
10+
// 無 prepareInput:確認父層鏈仍正確執行
11+
}

0 commit comments

Comments
 (0)