Skip to content

Commit b590a5f

Browse files
authored
Feature/improve ismodel type safety (#20)
* Feature/Enhance type safety and validation in model instantiation * Doc/Enhance documentation
1 parent 249d093 commit b590a5f

39 files changed

+739
-169
lines changed

README.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,13 @@ On top of those base traits **Complex Heart** provide ready to use compositions:
2626
- `IsEntity` composed by `IsModel`, `HasIdentity`, `HasEquality`.
2727
- `IsAggregate` composed by `IsEntity`, `HasDomainEvents`.
2828

29-
For more information please check the wiki.
29+
## Key Features
30+
31+
- **Type-Safe Factory Method**: The `make()` static factory validates constructor parameters at runtime with clear error messages
32+
- **Automatic Invariant Checking**: When using `make()`, Value Objects and Entities automatically validate invariants after construction (no manual `$this->check()` needed)
33+
- **Readonly Properties Support**: Full compatibility with PHP 8.1+ readonly properties
34+
- **PHPStan Level 8**: Complete static analysis support
35+
36+
> **Note:** Automatic invariant checking only works when using the `make()` factory method. Direct constructor calls require manual `$this->check()` in the constructor.
37+
38+
For more information and usage examples, please check the wiki.

composer.json

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@
2626
"pestphp/pest-plugin-faker": "^2.0",
2727
"phpstan/phpstan": "^1.0",
2828
"phpstan/extension-installer": "^1.3",
29-
"phpstan/phpstan-mockery": "^1.1"
29+
"phpstan/phpstan-mockery": "^1.1",
30+
"laravel/pint": "^1.25"
3031
},
3132
"autoload": {
3233
"psr-4": {
@@ -41,11 +42,12 @@
4142
"scripts": {
4243
"test": "vendor/bin/pest --configuration=phpunit.xml --coverage-clover=coverage.xml --log-junit=test.xml",
4344
"test-cov": "vendor/bin/pest --configuration=phpunit.xml --coverage-html=coverage",
44-
"analyse": "vendor/bin/phpstan analyse src --no-progress --level=8",
45+
"analyse": "vendor/bin/phpstan analyse src --no-progress --memory-limit=4G --level=8",
4546
"check": [
4647
"@analyse",
4748
"@test"
48-
]
49+
],
50+
"pint": "vendor/bin/pint --preset psr12"
4951
},
5052
"config": {
5153
"allow-plugins": {

src/Errors/ImmutabilityError.php

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,4 @@
1414
*/
1515
class ImmutabilityError extends Error
1616
{
17-
1817
}

src/Exceptions/InstantiationException.php

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,4 @@
1414
*/
1515
class InstantiationException extends RuntimeException
1616
{
17-
1817
}

src/Exceptions/InvariantViolation.php

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,4 @@
1414
*/
1515
class InvariantViolation extends Exception
1616
{
17-
1817
}

src/IsAggregate.php

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616
* @see https://martinfowler.com/bliki/EvansClassification.html
1717
*
1818
* @author Unay Santisteban <usantisteban@othercode.io>
19-
* @package ComplexHeart\Domain\Model\Traits
2019
*/
2120
trait IsAggregate
2221
{

src/IsEntity.php

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616
* @see https://martinfowler.com/bliki/EvansClassification.html
1717
*
1818
* @author Unay Santisteban <usantisteban@othercode.io>
19-
* @package ComplexHeart\Domain\Model\Traits
2019
*/
2120
trait IsEntity
2221
{
@@ -25,4 +24,14 @@ trait IsEntity
2524
use HasEquality {
2625
HasIdentity::hash insteadof HasEquality;
2726
}
27+
28+
/**
29+
* Entities have automatic invariant checking enabled by default.
30+
*
31+
* @return bool
32+
*/
33+
protected function shouldAutoCheckInvariants(): bool
34+
{
35+
return true;
36+
}
2837
}

src/IsModel.php

Lines changed: 172 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -4,30 +4,192 @@
44

55
namespace ComplexHeart\Domain\Model;
66

7-
use ComplexHeart\Domain\Model\Exceptions\InstantiationException;
87
use ComplexHeart\Domain\Model\Traits\HasAttributes;
98
use ComplexHeart\Domain\Model\Traits\HasInvariants;
10-
use Doctrine\Instantiator\Exception\ExceptionInterface;
11-
use Doctrine\Instantiator\Instantiator;
12-
use RuntimeException;
139

1410
/**
1511
* Trait IsModel
1612
*
13+
* Provides type-safe object instantiation with automatic invariant checking.
14+
*
15+
* Key improvements in this version:
16+
* - Type-safe make() method with validation
17+
* - Automatic invariant checking after construction
18+
* - Constructor as single source of truth
19+
* - Better error messages
20+
*
1721
* @author Unay Santisteban <usantisteban@othercode.io>
18-
* @package ComplexHeart\Domain\Model\Traits
1922
*/
2023
trait IsModel
2124
{
2225
use HasAttributes;
2326
use HasInvariants;
2427

2528
/**
26-
* Initialize the Model. Just as the constructor will do.
29+
* Create instance with type-safe validation.
30+
*
31+
* This method:
32+
* 1. Validates parameter types against constructor signature
33+
* 2. Creates instance through constructor (type-safe)
34+
* 3. Invariants are checked automatically after construction
35+
*
36+
* @param mixed ...$params Constructor parameters
37+
* @return static
38+
* @throws \InvalidArgumentException When required parameters are missing
39+
* @throws \TypeError When parameter types don't match
40+
*/
41+
final public static function make(mixed ...$params): static
42+
{
43+
$reflection = new \ReflectionClass(static::class);
44+
$constructor = $reflection->getConstructor();
45+
46+
if (!$constructor) {
47+
throw new \RuntimeException(
48+
sprintf('%s must have a constructor to use make()', static::class)
49+
);
50+
}
51+
52+
// Validate parameters against constructor signature
53+
// array_values ensures we have a proper indexed array
54+
self::validateConstructorParameters($constructor, array_values($params));
55+
56+
// Create instance through constructor (PHP handles type enforcement)
57+
// @phpstan-ignore-next-line - new static() is safe here as we validated the constructor
58+
$instance = new static(...$params);
59+
60+
// Auto-check invariants if enabled
61+
$instance->autoCheckInvariants();
62+
63+
return $instance;
64+
}
65+
66+
/**
67+
* Validate parameters match constructor signature.
68+
*
69+
* @param \ReflectionMethod $constructor
70+
* @param array<int, mixed> $params
71+
* @return void
72+
* @throws \InvalidArgumentException
73+
* @throws \TypeError
74+
*/
75+
private static function validateConstructorParameters(
76+
\ReflectionMethod $constructor,
77+
array $params
78+
): void {
79+
$constructorParams = $constructor->getParameters();
80+
$required = $constructor->getNumberOfRequiredParameters();
81+
82+
// Check parameter count
83+
if (count($params) < $required) {
84+
$missing = array_slice($constructorParams, count($params), $required - count($params));
85+
$names = array_map(fn ($p) => $p->getName(), $missing);
86+
throw new \InvalidArgumentException(
87+
sprintf(
88+
'%s::make() missing required parameters: %s',
89+
basename(str_replace('\\', '/', static::class)),
90+
implode(', ', $names)
91+
)
92+
);
93+
}
94+
95+
// Validate types for each parameter
96+
foreach ($constructorParams as $index => $param) {
97+
if (!isset($params[$index])) {
98+
continue; // Optional parameter not provided
99+
}
100+
101+
$value = $params[$index];
102+
$type = $param->getType();
103+
104+
if (!$type instanceof \ReflectionNamedType) {
105+
continue; // No type hint or union type
106+
}
107+
108+
$typeName = $type->getName();
109+
$isValid = self::validateType($value, $typeName, $type->allowsNull());
110+
111+
if (!$isValid) {
112+
throw new \TypeError(
113+
sprintf(
114+
'%s::make() parameter "%s" must be of type %s, %s given',
115+
basename(str_replace('\\', '/', static::class)),
116+
$param->getName(),
117+
$typeName,
118+
get_debug_type($value)
119+
)
120+
);
121+
}
122+
}
123+
}
124+
125+
/**
126+
* Validate a value matches expected type.
127+
*
128+
* @param mixed $value
129+
* @param string $typeName
130+
* @param bool $allowsNull
131+
* @return bool
132+
*/
133+
private static function validateType(mixed $value, string $typeName, bool $allowsNull): bool
134+
{
135+
if ($value === null) {
136+
return $allowsNull;
137+
}
138+
139+
return match($typeName) {
140+
'int' => is_int($value),
141+
'float' => is_float($value) || is_int($value), // Allow int for float
142+
'string' => is_string($value),
143+
'bool' => is_bool($value),
144+
'array' => is_array($value),
145+
'object' => is_object($value),
146+
'callable' => is_callable($value),
147+
'iterable' => is_iterable($value),
148+
'mixed' => true,
149+
default => $value instanceof $typeName
150+
};
151+
}
152+
153+
/**
154+
* Determine if invariants should be checked automatically after construction.
155+
*
156+
* Override this method in your class to disable auto-check:
157+
*
158+
* protected function shouldAutoCheckInvariants(): bool
159+
* {
160+
* return false;
161+
* }
162+
*
163+
* @return bool
164+
*/
165+
protected function shouldAutoCheckInvariants(): bool
166+
{
167+
return false; // Disabled by default for backward compatibility
168+
}
169+
170+
/**
171+
* Called after construction to auto-check invariants.
172+
*
173+
* This method is automatically called after the constructor completes
174+
* if shouldAutoCheckInvariants() returns true.
175+
*
176+
* @return void
177+
*/
178+
private function autoCheckInvariants(): void
179+
{
180+
if ($this->shouldAutoCheckInvariants()) {
181+
$this->check();
182+
}
183+
}
184+
185+
/**
186+
* Initialize the Model (legacy method - DEPRECATED).
187+
*
188+
* @deprecated Use constructor with make() factory method instead.
189+
* This method will be removed in v1.0.0
27190
*
28191
* @param array<int|string, mixed> $source
29192
* @param string|callable $onFail
30-
*
31193
* @return static
32194
*/
33195
protected function initialize(array $source, string|callable $onFail = 'invariantHandler'): static
@@ -39,17 +201,16 @@ protected function initialize(array $source, string|callable $onFail = 'invarian
39201
}
40202

41203
/**
42-
* Transform an indexed array into assoc array by combining the
43-
* given values with the list of attributes of the object.
204+
* Transform an indexed array into assoc array (legacy method - DEPRECATED).
44205
*
206+
* @deprecated This method will be removed in v1.0.0
45207
* @param array<int|string, mixed> $source
46-
*
47208
* @return array<string, mixed>
48209
*/
49210
private function prepareAttributes(array $source): array
50211
{
51212
// check if the array is indexed or associative.
52-
$isIndexed = fn($source): bool => ([] !== $source) && array_keys($source) === range(0, count($source) - 1);
213+
$isIndexed = fn ($source): bool => ([] !== $source) && array_keys($source) === range(0, count($source) - 1);
53214

54215
/** @var array<string, mixed> $source */
55216
return $isIndexed($source)
@@ -58,22 +219,4 @@ private function prepareAttributes(array $source): array
58219
// return the already mapped array source.
59220
: $source;
60221
}
61-
62-
/**
63-
* Restore the instance without calling __constructor of the model.
64-
*
65-
* @return static
66-
*
67-
* @throws RuntimeException
68-
*/
69-
final public static function make(): static
70-
{
71-
try {
72-
return (new Instantiator())
73-
->instantiate(static::class)
74-
->initialize(func_get_args());
75-
} catch (ExceptionInterface $e) {
76-
throw new InstantiationException($e->getMessage(), $e->getCode(), $e);
77-
}
78-
}
79222
}

src/IsValueObject.php

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,19 +13,31 @@
1313
* > A small simple object, like money or a date range, whose equality isn't based on identity.
1414
* > -- Martin Fowler
1515
*
16+
* Value Objects have automatic invariant checking enabled by default when using the make() factory method.
17+
* For direct constructor usage, you must manually call $this->check() at the end of your constructor.
18+
*
1619
* @see https://martinfowler.com/eaaCatalog/valueObject.html
1720
* @see https://martinfowler.com/bliki/ValueObject.html
1821
* @see https://martinfowler.com/bliki/EvansClassification.html
1922
*
2023
* @author Unay Santisteban <usantisteban@othercode.io>
21-
* @package ComplexHeart\Domain\Model\Traits
2224
*/
2325
trait IsValueObject
2426
{
2527
use IsModel;
2628
use HasEquality;
2729
use HasImmutability;
2830

31+
/**
32+
* Value Objects have automatic invariant checking enabled by default.
33+
*
34+
* @return bool
35+
*/
36+
protected function shouldAutoCheckInvariants(): bool
37+
{
38+
return true;
39+
}
40+
2941
/**
3042
* Represents the object as String.
3143
*

src/Traits/HasAttributes.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ final public static function attributes(): array
2424
{
2525
return array_filter(
2626
array_keys(get_class_vars(static::class)),
27-
fn(string $item): bool => !str_starts_with($item, '_')
27+
fn (string $item): bool => !str_starts_with($item, '_')
2828
);
2929
}
3030

@@ -118,7 +118,7 @@ protected function getStringKey(string $id, string $prefix = '', string $suffix
118118
return sprintf(
119119
'%s%s%s',
120120
$prefix,
121-
implode('', map(fn(string $chunk): string => ucfirst($chunk), explode('_', $id))),
121+
implode('', map(fn (string $chunk): string => ucfirst($chunk), explode('_', $id))),
122122
$suffix
123123
);
124124
}

0 commit comments

Comments
 (0)