Skip to content

Commit 733d738

Browse files
committed
Feature/aggregate invariant violations and enhance InvariantViolation exception handling
1 parent c744ad9 commit 733d738

File tree

6 files changed

+212
-2
lines changed

6 files changed

+212
-2
lines changed

src/Exceptions/InvariantViolation.php

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,73 @@
99
/**
1010
* Class InvariantViolation
1111
*
12+
* Exception thrown when one or more invariants are violated.
13+
*
1214
* @author Unay Santisteban <usantisteban@othercode.io>
1315
* @package ComplexHeart\Domain\Model\Exceptions
1416
*/
1517
class InvariantViolation extends Exception
1618
{
19+
/**
20+
* @var array<int, string> List of all violation messages
21+
*/
22+
private array $violations = [];
23+
24+
/**
25+
* Create an invariant violation exception from one or more violations.
26+
*
27+
* @param array<int, string> $violations
28+
* @param int $code
29+
* @param Exception|null $previous
30+
* @return self
31+
*/
32+
public static function fromViolations(array $violations, int $code = 0, ?Exception $previous = null): self
33+
{
34+
$count = count($violations);
35+
36+
// Format message based on count
37+
if ($count === 1) {
38+
$message = $violations[0];
39+
} else {
40+
$message = sprintf(
41+
"Multiple invariant violations (%d):\n- %s",
42+
$count,
43+
implode("\n- ", $violations)
44+
);
45+
}
46+
47+
$exception = new self($message, $code, $previous);
48+
$exception->violations = $violations;
49+
return $exception;
50+
}
51+
52+
/**
53+
* Check if this exception has multiple violations.
54+
*
55+
* @return bool
56+
*/
57+
public function hasMultipleViolations(): bool
58+
{
59+
return count($this->violations) > 1;
60+
}
61+
62+
/**
63+
* Get all violation messages.
64+
*
65+
* @return array<int, string>
66+
*/
67+
public function getViolations(): array
68+
{
69+
return $this->violations;
70+
}
71+
72+
/**
73+
* Get the count of violations.
74+
*
75+
* @return int
76+
*/
77+
public function getViolationCount(): int
78+
{
79+
return count($this->violations);
80+
}
1781
}

src/IsModel.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,21 @@ final public static function make(mixed ...$params): static
7575
return $instance;
7676
}
7777

78+
/**
79+
* Alias for make() method - more idiomatic for domain objects.
80+
*
81+
* Example: Customer::new(id: $id, name: 'John Doe')
82+
*
83+
* @param mixed ...$params Constructor parameters
84+
* @return static
85+
* @throws InvalidArgumentException When required parameters are missing
86+
* @throws TypeError When parameter types don't match
87+
*/
88+
final public static function new(mixed ...$params): static
89+
{
90+
return static::make(...$params);
91+
}
92+
7893
/**
7994
* Validate parameters match constructor signature.
8095
*

src/Traits/HasInvariants.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,13 @@ private function computeInvariantHandler(string|callable $handlerFn, string $exc
121121
$this->{$handlerFn}($violations, $exception);
122122
}
123123
: function (array $violations) use ($exception): void {
124+
// Always aggregate violations for InvariantViolation
125+
if ($exception === InvariantViolation::class) {
126+
$messages = map(fn (Throwable $e): string => $e->getMessage(), $violations);
127+
throw InvariantViolation::fromViolations(array_values($messages));
128+
}
129+
130+
// Legacy behavior for custom exception classes
124131
if (count($violations) === 1) {
125132
throw array_shift($violations);
126133
}

tests/TraitsTest.php

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,4 +76,92 @@ protected function invariantAlwaysFailTwo(): bool
7676
};
7777
})
7878
->group('Unit')
79-
->throws(InvariantViolation::class, 'always fail one, always fail two');
79+
->throws(InvariantViolation::class, 'Multiple invariant violations (2)');
80+
81+
test('InvariantViolation should support multiple violations aggregation', function () {
82+
try {
83+
new class () {
84+
use HasInvariants;
85+
86+
public function __construct()
87+
{
88+
$this->check();
89+
}
90+
91+
protected function invariantAlwaysFailOne(): bool
92+
{
93+
return false;
94+
}
95+
96+
protected function invariantAlwaysFailTwo(): bool
97+
{
98+
return false;
99+
}
100+
101+
protected function invariantAlwaysFailThree(): bool
102+
{
103+
return false;
104+
}
105+
};
106+
} catch (InvariantViolation $e) {
107+
expect($e->hasMultipleViolations())->toBeTrue()
108+
->and($e->getViolationCount())->toBe(3)
109+
->and($e->getViolations())->toHaveCount(3)
110+
->and($e->getViolations())->toContain('always fail one')
111+
->and($e->getViolations())->toContain('always fail two')
112+
->and($e->getViolations())->toContain('always fail three')
113+
->and($e->getMessage())->toContain('Multiple invariant violations (3)');
114+
}
115+
})
116+
->group('Unit');
117+
118+
test('InvariantViolation::fromViolations should handle single violation cleanly', function () {
119+
try {
120+
new class () {
121+
use HasInvariants;
122+
123+
public function __construct()
124+
{
125+
$this->check();
126+
}
127+
128+
protected function invariantSingleFailure(): bool
129+
{
130+
return false;
131+
}
132+
};
133+
} catch (InvariantViolation $e) {
134+
expect($e->hasMultipleViolations())->toBeFalse()
135+
->and($e->getViolationCount())->toBe(1)
136+
->and($e->getViolations())->toBe(['single failure'])
137+
->and($e->getMessage())->toBe('single failure')
138+
->and($e->getMessage())->not->toContain('Multiple invariant violations');
139+
}
140+
})
141+
->group('Unit');
142+
143+
test('InvariantViolation::fromViolations should format multiple violations', function () {
144+
$violations = ['First error', 'Second error', 'Third error'];
145+
$exception = InvariantViolation::fromViolations($violations);
146+
147+
expect($exception)->toBeInstanceOf(InvariantViolation::class)
148+
->and($exception->hasMultipleViolations())->toBeTrue()
149+
->and($exception->getViolationCount())->toBe(3)
150+
->and($exception->getViolations())->toBe($violations)
151+
->and($exception->getMessage())->toContain('Multiple invariant violations (3)')
152+
->and($exception->getMessage())->toContain('First error')
153+
->and($exception->getMessage())->toContain('Second error')
154+
->and($exception->getMessage())->toContain('Third error');
155+
})
156+
->group('Unit');
157+
158+
test('InvariantViolation::fromViolations with single violation should not show count', function () {
159+
$exception = InvariantViolation::fromViolations(['Single error message']);
160+
161+
expect($exception)->toBeInstanceOf(InvariantViolation::class)
162+
->and($exception->hasMultipleViolations())->toBeFalse()
163+
->and($exception->getViolationCount())->toBe(1)
164+
->and($exception->getViolations())->toBe(['Single error message'])
165+
->and($exception->getMessage())->toBe('Single error message');
166+
})
167+
->group('Unit');

tests/TypeValidationTest.php

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,3 +204,39 @@
204204
test('make() should validate union types with named parameters', function () {
205205
Money::make(amount: 'invalid', currency: 'USD');
206206
})->throws(TypeError::class, 'parameter "amount" must be of type int|float');
207+
208+
test('new() should work as alias for make()', function () {
209+
$email = Email::new('test@example.com');
210+
211+
expect($email)->toBeInstanceOf(Email::class)
212+
->and((string) $email)->toBe('test@example.com');
213+
});
214+
215+
test('new() should support positional parameters', function () {
216+
$money = Money::new(100, 'USD');
217+
218+
expect($money)->toBeInstanceOf(Money::class)
219+
->and((string) $money)->toBe('100 USD');
220+
});
221+
222+
test('new() should support named parameters', function () {
223+
$email = Email::new(value: 'user@example.com');
224+
225+
expect($email)->toBeInstanceOf(Email::class)
226+
->and((string) $email)->toBe('user@example.com');
227+
});
228+
229+
test('new() should support named parameters in any order', function () {
230+
$money = Money::new(currency: 'EUR', amount: 99.99);
231+
232+
expect($money)->toBeInstanceOf(Money::class)
233+
->and((string) $money)->toBe('99.99 EUR');
234+
});
235+
236+
test('new() should throw TypeError for invalid types', function () {
237+
Email::new(123);
238+
})->throws(TypeError::class, 'parameter "value" must be of type string, int given');
239+
240+
test('new() should validate union types', function () {
241+
Money::new(['invalid'], 'USD');
242+
})->throws(TypeError::class, 'parameter "amount" must be of type int|float');

tests/ValueObjectsTest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@
5555
protected string $_pattern = '[a-z]';
5656
};
5757
})
58-
->throws(InvalidArgumentException::class)
58+
->throws(InvariantViolation::class)
5959
->group('Unit');
6060

6161
test('BooleanValue should create a valid BooleanValue Object.', function () {

0 commit comments

Comments
 (0)