Skip to content

Commit 9af168a

Browse files
author
Administrator
committed
[feat] object/validation
1. Added HasValidate interface defining the validate method. 2. Introduced abstract classes SingleValueObject, extending ValueObject and implementing HasValidate. 3. Changed the visibility of the constructInitialize method to final protected. 4. Enhanced walkProperties for better stability by ensuring reflection reinitializes when missing. 5. Added toJson method to support JSON encoding.
1 parent 8aee686 commit 9af168a

File tree

7 files changed

+323
-21
lines changed

7 files changed

+323
-21
lines changed

CHANGELOG.md

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

3+
## [v3.2.0-alpha.1] - 2025-10-31
4+
5+
* Added HasValidate interface defining the validate method.
6+
* Introduced abstract classes SingleValueObject, extending ValueObject and implementing HasValidate.
7+
* Changed the visibility of the constructInitialize method to final protected.
8+
* Enhanced walkProperties for better stability by ensuring reflection reinitializes when missing.
9+
* Added toJson method to support JSON encoding.
10+
311
## [v3.1.0] - 2025-10-29
412

513
### ⚠️Note

README.md

Lines changed: 74 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,18 @@ $user = User::fromArray([
123123
$user = Money::fromJson('{"value": 1000}');
124124
```
125125

126+
### Exporting — `toArray()`, `toJson()`
127+
128+
```php
129+
// ['name' => 'Kip', 'age' => 18]
130+
$user->toArray();
131+
```
132+
133+
```php
134+
// {"name":"Kip","age":18}
135+
$user->toJson();
136+
```
137+
126138
### Updating — `with()`
127139

128140
> ⚠️ This does **not** mutate the original object. A new instance is returned with partial updates, by design. For the underlying rationale, see [Objects and references](https://www.php.net/manual/en/language.oop5.references.php).<br>
@@ -143,11 +155,25 @@ $userWithNewAddress = $user->with([
143155
]);
144156
```
145157

146-
### Exporting — `toArray()`
158+
## API for SingleValueObject only
147159

160+
### Constructing - `from()`
148161
```php
149-
// ['name' => 'Kip', 'age' => 18]
150-
$user->toArray();
162+
$email = Gmail::from('[email protected]');
163+
```
164+
165+
### Comparing - `equals()`
166+
Compares the current object with another instance of the same class.
167+
If the provided value is not an instance of the same class, an exception will be thrown.
168+
This method returns true only when both objects contain an identical $value.
169+
```php
170+
$email2 = Gmail::from('[email protected]');
171+
$email3 = Gmail::from('[email protected]');
172+
$email4 = Hotmail::from('[email protected]');
173+
174+
$email->equals($email2); // true
175+
$email->equals($email3); // false
176+
$email->equals($email4); // Exception thrown
151177
```
152178

153179
## Architecture: Attributes
@@ -289,6 +315,51 @@ class Money extends ValueObject
289315
}
290316
```
291317

318+
### `SingleValueObject`
319+
320+
Designed **exclusively for single-value inheritance** — do **not** extend it for multi-property value objects.
321+
Classes extending `SingleValueObject` are **required** to declare exactly one property:
322+
`private readonly {type} $value`.
323+
324+
```php
325+
use ReallifeKip\ImmutableBase\Objects\SingleValueObject;
326+
327+
class Email extends SingleValueObject
328+
{
329+
protected readonly string $value;
330+
public function validate(): bool
331+
{
332+
return str_contains($this->value, '@');
333+
}
334+
}
335+
336+
class Gmail extends Email
337+
{
338+
public function validate(): bool
339+
{
340+
return str_contains($this->value, 'gmail.com');
341+
}
342+
}
343+
```
344+
345+
> The `validate()` method defines validation rules automatically executed during initialization.
346+
> If the class hierarchy includes multiple levels of inheritance, all `validate()` methods in the chain will be called upward until every one passes before construction completes.
347+
348+
### Output
349+
350+
```php
351+
$email = Email::from('[email protected]');
352+
353+
// [email protected] ⚠️ Works only if $value is a string
354+
echo $email;
355+
356+
echo $email();
357+
358+
echo $email->value;
359+
```
360+
361+
All of the above expressions produce the same result.
362+
292363
## Notes
293364

294365
1. **Property types** must be explicitly declared; `mixed` is not allowed.

README_TW.md

Lines changed: 77 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ final class Money extends ValueObject
109109

110110
## API
111111

112-
### 建構物件 - fromArray(), fromJson()
112+
### 建構物件 - `fromArray()`, `fromJson()`
113113

114114
> 傳入參數掃描時,若發現參數內容非物件宣告的屬性,該參數將被自動忽略而不會存在於返回的實例。
115115
@@ -124,7 +124,19 @@ $user = User::fromArray([
124124
$user = Money::fromJson('{"value": 1000}');
125125
```
126126

127-
### 修改屬性 - with()
127+
### 輸出陣列 - `toArray()` , `toJson()`
128+
129+
```php
130+
// ['name' => 'Kip', 'age' => 18]
131+
$user->toArray();
132+
```
133+
134+
```php
135+
// {"name":"Kip","age":18}
136+
$user->toJson();
137+
```
138+
139+
### 修改屬性 - `with()`
128140

129141
> ⚠️ 注意:非修改原始物件,而是基於原始物件進行部分修改後返回 `新實例`,採用此設計的原因及底層原理請參考 [Objects and references](https://www.php.net/manual/en/language.oop5.references.php)。<br>
130142
> ⚠️ 注意:當 with() 指定修改 #[ArrayOf] 屬性時會直接重建陣列。<br>
@@ -144,11 +156,26 @@ $userWithNewAddress = $user->with([
144156
]);
145157
```
146158

147-
### 輸出陣列 - toArray()
159+
## 僅適用於 SingleValueObject 的 API
160+
161+
### 建構物件 - `from()`
162+
163+
```php
164+
$email = Gmail::from('[email protected]');
165+
```
166+
167+
### 比較方法 - `equals()`
168+
169+
比較當前物件與**相同類別**的另一個實例,若傳入的值不是相同類別的實例將會拋出例外,當兩個物件內部的 `$value` 值相同時,才會回傳 `true`
148170

149171
```php
150-
// ['name' => 'Kip', 'age' => 18]
151-
$user->toArray();
172+
$email2 = Gmail::from('[email protected]');
173+
$email3 = Gmail::from('[email protected]');
174+
$email4 = Hotmail::from('[email protected]');
175+
176+
$email->equals($email2); // true
177+
$email->equals($email3); // false
178+
$email->equals($email4); // 拋出例外
152179
```
153180

154181
## 架構模式標註
@@ -291,6 +318,51 @@ class Money extends ValueObject
291318
}
292319
```
293320

321+
### `SingleValueObject`
322+
323+
專為**單一值(single-value)繼承**而設計 —— **不要**在此基礎上建立多屬性的 Value Object。
324+
325+
繼承 `SingleValueObject` 的類別**必須**宣告:`private readonly {type} $value`
326+
327+
```php
328+
use ReallifeKip\ImmutableBase\Objects\SingleValueObject;
329+
330+
class Email extends SingleValueObject
331+
{
332+
protected readonly string $value;
333+
public function validate(): bool
334+
{
335+
return str_contains($this->value, '@');
336+
}
337+
}
338+
339+
class Gmail extends Email
340+
{
341+
public function validate(): bool
342+
{
343+
return str_contains($this->value, 'gmail.com');
344+
}
345+
}
346+
```
347+
348+
> `validate()` 方法用於定義驗證規則,會在初始化時自動執行。
349+
> 若類別存在多層繼承關係,所有父類與子類中的 `validate()` 都會依序被呼叫,直到全部驗證通過後,物件才會完成建構。
350+
351+
### 輸出結果
352+
353+
```php
354+
$email = Email::from('[email protected]');
355+
356+
// [email protected] ⚠️ 透過 __toString() 包裝,僅 $value 宣告 type 為字串時可用
357+
echo $email;
358+
359+
echo $email();
360+
361+
echo $email->value;
362+
```
363+
364+
以上幾種寫法的輸出結果都相同。
365+
294366
## ⚠️ 注意事項
295367

296368
1. **屬性型別**:必須宣告屬性型別,且不允許為 mixed,需明確宣告。

src/ImmutableBase.php

Lines changed: 51 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
use ReflectionProperty;
1111
use ReflectionNamedType;
1212
use ReflectionUnionType;
13+
use ReallifeKip\ImmutableBase\Objects\ValueObject;
14+
use ReallifeKip\ImmutableBase\Objects\SingleValueObject;
1315
use ReallifeKip\ImmutableBase\Exceptions\AttributeException;
1416
use ReallifeKip\ImmutableBase\Exceptions\InvalidJsonException;
1517
use ReallifeKip\ImmutableBase\Exceptions\InvalidTypeException;
@@ -292,7 +294,7 @@ private static function extendsValidate()
292294
*
293295
* @throws AttributeException When the subclass does not extend any supported immutable base type.
294296
*/
295-
private function constructInitialize()
297+
final protected function constructInitialize()
296298
{
297299
$attrNamespace = "$this->namespace\\Attributes";
298300
$this->ref ??= self::getReflection($this);
@@ -361,8 +363,15 @@ private static function getReflection(object $obj): ReflectionClass
361363
private function walkProperties(callable $callback): void
362364
{
363365
$properties = [];
364-
for ($c = $this->ref; $c && $c->name !== self::class; $c = $c->getParentClass()) {
365-
array_unshift($properties, ...$c->getProperties());
366+
if (!$this->ref) {
367+
$this->ref = new ReflectionClass($this);
368+
}
369+
$c = $this->ref;
370+
while ($c) {
371+
if ($c->name !== self::class) {
372+
array_unshift($properties, ...$c->getProperties());
373+
}
374+
$c = $c->getParentClass();
366375
}
367376
foreach ($properties as $p) {
368377
/** @var ReflectionProperty $p */
@@ -457,12 +466,22 @@ final public function toArray(): array
457466
{
458467
$properties = [];
459468
$this->walkProperties(function (ReflectionProperty $property) use (&$properties) {
460-
$properties[$property->name] = is_array($value = $property->getValue($this)) ?
461-
array_map([$this, 'toArrayOrValue'], $value) :
462-
$this->toArrayOrValue($value);
469+
$type = $property->getType()->getName();
470+
$value = $property->getValue($this);
471+
if (is_subclass_of($type, SingleValueObject::class)) {
472+
$properties[$property->name] = $value();
473+
} else {
474+
$properties[$property->name] = is_array($value) ?
475+
array_map([$this, 'toArrayOrValue'], $value) :
476+
$this->toArrayOrValue($value);
477+
}
463478
});
464479
return $properties;
465480
}
481+
final public function toJson()
482+
{
483+
return json_encode($this->toArray());
484+
}
466485
/**
467486
* Converts an object to an array if possible, otherwise returns the original value.
468487
*
@@ -482,6 +501,10 @@ private function toArrayOrValue(mixed $value)
482501
if (is_object($value)) {
483502
if (method_exists($value, 'toArray')) {
484503
return $value->toArray();
504+
} elseif (property_exists($value, 'value')) {
505+
return $value->value;
506+
} elseif (property_exists($value, 'name')) {
507+
return $value->name;
485508
}
486509
}
487510
return $value;
@@ -587,19 +610,27 @@ private function namedTypeDecide(ReflectionNamedType $type, mixed $value)
587610
is_array($value) && is_subclass_of($class, self::class) => $class::fromArray($value),
588611
is_object($value) => $value,
589612
$this->validNullValue($type, $value) => null,
590-
is_string($value) && enum_exists($class) => (function () use ($class, $value) {
591-
try {
592-
return $class::tryFrom($value) ?? constant("$class::$value");
593-
} catch (Throwable) {
594-
throw new InvalidTypeException("is $class and does not include '$value'.");
595-
}
596-
})(),
613+
$this->isBuiltin($value) => $this->singleValueDecide($class, $value),
597614
default => throw new InvalidTypeException(
598615
"expected types: $class, got " .
599616
(is_object($value) ? $value::class : gettype($value)) . '.'
600617
)
601618
};
602619
}
620+
private function singleValueDecide($class, $value)
621+
{
622+
if (enum_exists($class)) {
623+
try {
624+
return (is_subclass_of($class, \BackedEnum::class) && $case = $class::tryFrom($value)) ? $case : constant("$class::$value");
625+
} catch (Throwable) {
626+
throw new InvalidTypeException("is $class and does not include '$value'.");
627+
}
628+
} elseif (is_subclass_of($class, ValueObject::class)) {
629+
if (is_subclass_of($class, SingleValueObject::class)) {
630+
return $class::from($value);
631+
}
632+
}
633+
}
603634
/**
604635
* Determines whether the given value is a valid null according to the property's type definition.
605636
*
@@ -632,4 +663,11 @@ private function builtinTypeValidate(mixed $value, string $type): bool
632663
default => false,
633664
};
634665
}
666+
private function isBuiltin(mixed $value)
667+
{
668+
return in_array(gettype($value), [
669+
'integer', 'double', 'string', 'boolean',
670+
'array', 'object', 'resource', 'NULL'
671+
], true);
672+
}
635673
}

src/Interfaces/HasValidate.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<?php
2+
3+
namespace ReallifeKip\ImmutableBase\Interfaces;
4+
5+
interface HasValidate
6+
{
7+
public function validate(): bool;
8+
}

0 commit comments

Comments
 (0)