diff --git a/src/Contracts/Aggregatable.php b/src/Contracts/Aggregatable.php new file mode 100644 index 0000000..d734f77 --- /dev/null +++ b/src/Contracts/Aggregatable.php @@ -0,0 +1,21 @@ + + * @package ComplexHeart\Domain\Model\Contracts + */ +interface Aggregatable +{ +} diff --git a/src/Exceptions/InvariantViolation.php b/src/Exceptions/InvariantViolation.php index 8ab5d43..bcedd16 100644 --- a/src/Exceptions/InvariantViolation.php +++ b/src/Exceptions/InvariantViolation.php @@ -4,6 +4,7 @@ namespace ComplexHeart\Domain\Model\Exceptions; +use ComplexHeart\Domain\Model\Contracts\Aggregatable; use Exception; /** @@ -12,6 +13,68 @@ * @author Unay Santisteban * @package ComplexHeart\Domain\Model\Exceptions */ -class InvariantViolation extends Exception +class InvariantViolation extends Exception implements Aggregatable { + /** + * @var array List of all violation messages + */ + private array $violations = []; + + /** + * Create an invariant violation exception from one or more violations. + * + * @param array $violations + * @param int $code + * @param Exception|null $previous + * @return self + */ + public static function fromViolations(array $violations, int $code = 0, ?Exception $previous = null): self + { + $count = count($violations); + + // Format message based on count + if ($count === 1) { + $message = $violations[0]; + } else { + $message = sprintf( + "Multiple invariant violations (%d):\n- %s", + $count, + implode("\n- ", $violations) + ); + } + + $exception = new self($message, $code, $previous); + $exception->violations = $violations; + return $exception; + } + + /** + * Check if this exception has multiple violations. + * + * @return bool + */ + public function hasMultipleViolations(): bool + { + return count($this->violations) > 1; + } + + /** + * Get all violation messages. + * + * @return array + */ + public function getViolations(): array + { + return $this->violations; + } + + /** + * Get the count of violations. + * + * @return int + */ + public function getViolationCount(): int + { + return count($this->violations); + } } diff --git a/src/IsModel.php b/src/IsModel.php index 1b56146..15c9b82 100644 --- a/src/IsModel.php +++ b/src/IsModel.php @@ -75,6 +75,21 @@ final public static function make(mixed ...$params): static return $instance; } + /** + * Alias for make() method - more idiomatic for domain objects. + * + * Example: Customer::new(id: $id, name: 'John Doe') + * + * @param mixed ...$params Constructor parameters + * @return static + * @throws InvalidArgumentException When required parameters are missing + * @throws TypeError When parameter types don't match + */ + final public static function new(mixed ...$params): static + { + return static::make(...$params); + } + /** * Validate parameters match constructor signature. * @@ -128,7 +143,7 @@ private static function validateConstructorParameters( // Union type (e.g., int|float|string) $isValid = self::validateUnionType($value, $type); $expectedTypes = implode('|', array_map( - fn($t) => $t instanceof ReflectionNamedType ? $t->getName() : 'mixed', + fn ($t) => $t instanceof ReflectionNamedType ? $t->getName() : 'mixed', $type->getTypes() )); } else { diff --git a/src/Traits/HasInvariants.php b/src/Traits/HasInvariants.php index 909ad0f..d019090 100644 --- a/src/Traits/HasInvariants.php +++ b/src/Traits/HasInvariants.php @@ -4,6 +4,7 @@ namespace ComplexHeart\Domain\Model\Traits; +use ComplexHeart\Domain\Model\Contracts\Aggregatable; use ComplexHeart\Domain\Model\Exceptions\InvariantViolation; use Throwable; @@ -110,6 +111,15 @@ private function computeInvariantViolations(string $exception): array return $violations; } + /** + * Compute the invariant handler function. + * + * The handler is responsible for throwing exceptions (single or aggregated). + * + * @param string|callable $handlerFn + * @param string $exception + * @return callable + */ private function computeInvariantHandler(string|callable $handlerFn, string $exception): callable { if (!is_string($handlerFn)) { @@ -121,17 +131,45 @@ private function computeInvariantHandler(string|callable $handlerFn, string $exc $this->{$handlerFn}($violations, $exception); } : function (array $violations) use ($exception): void { - if (count($violations) === 1) { - throw array_shift($violations); + $this->throwInvariantViolations($violations, $exception); + }; + } + + /** + * Throw invariant violations (single or aggregated). + * + * Responsible for all exception throwing logic: + * - Non-aggregatable exceptions: throw the first one immediately + * - Aggregatable exceptions: aggregate and throw as InvariantViolation + * + * @param array $violations + * @param string $exception + * @return void + * @throws Throwable + */ + private function throwInvariantViolations(array $violations, string $exception): void + { + // Separate aggregatable from non-aggregatable violations + $aggregatable = []; + $nonAggregatable = []; + + foreach ($violations as $key => $violation) { + if ($violation instanceof Aggregatable) { + $aggregatable[$key] = $violation; + } else { + $nonAggregatable[$key] = $violation; } + } - throw new $exception( // @phpstan-ignore-line - sprintf( - "Unable to create %s due: %s", - basename(str_replace('\\', '/', static::class)), - implode(", ", map(fn (Throwable $e): string => $e->getMessage(), $violations)), - ) - ); - }; + // If there are non-aggregatable exceptions, throw the first one immediately + if (!empty($nonAggregatable)) { + throw array_shift($nonAggregatable); + } + + // All violations are aggregatable - aggregate them + if (!empty($aggregatable)) { + $messages = map(fn (Throwable $e): string => $e->getMessage(), $aggregatable); + throw InvariantViolation::fromViolations(array_values($messages)); + } } } diff --git a/tests/TraitsTest.php b/tests/TraitsTest.php index f17db32..2e9a381 100644 --- a/tests/TraitsTest.php +++ b/tests/TraitsTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use ComplexHeart\Domain\Model\Contracts\Aggregatable; use ComplexHeart\Domain\Model\Errors\ImmutabilityError; use ComplexHeart\Domain\Model\Exceptions\InvariantViolation; use ComplexHeart\Domain\Model\Test\Fixtures\OrderManagement\Domain\Errors\InvalidPriceError; @@ -17,8 +18,8 @@ test('Object with HasImmutability should expose primitive values.', function () { $price = new Price(100.0, 'EUR'); - expect($price->amount)->toBeFloat(); - expect($price->currency)->toBeString(); + expect($price->amount)->toBeFloat() + ->and($price->currency)->toBeString(); }) ->group('Unit'); @@ -26,8 +27,8 @@ $price = new Price(100.0, 'EUR'); $newPrice = $price->applyDiscount(10.0); - expect($newPrice)->toBeInstanceOf(Price::class); - expect($newPrice->amount)->toBe(90.0); + expect($newPrice)->toBeInstanceOf(Price::class) + ->and($newPrice->amount)->toBe(90.0); }) ->group('Unit'); @@ -76,4 +77,201 @@ protected function invariantAlwaysFailTwo(): bool }; }) ->group('Unit') - ->throws(InvariantViolation::class, 'always fail one, always fail two'); + ->throws(InvariantViolation::class, 'Multiple invariant violations (2)'); + +test('InvariantViolation should support multiple violations aggregation', function () { + try { + new class () { + use HasInvariants; + + public function __construct() + { + $this->check(); + } + + protected function invariantAlwaysFailOne(): bool + { + return false; + } + + protected function invariantAlwaysFailTwo(): bool + { + return false; + } + + protected function invariantAlwaysFailThree(): bool + { + return false; + } + }; + } catch (InvariantViolation $e) { + expect($e->hasMultipleViolations())->toBeTrue() + ->and($e->getViolationCount())->toBe(3) + ->and($e->getViolations())->toHaveCount(3) + ->and($e->getViolations())->toContain('always fail one') + ->and($e->getViolations())->toContain('always fail two') + ->and($e->getViolations())->toContain('always fail three') + ->and($e->getMessage())->toContain('Multiple invariant violations (3)'); + } +}) + ->group('Unit'); + +test('InvariantViolation::fromViolations should handle single violation cleanly', function () { + try { + new class () { + use HasInvariants; + + public function __construct() + { + $this->check(); + } + + protected function invariantSingleFailure(): bool + { + return false; + } + }; + } catch (InvariantViolation $e) { + expect($e->hasMultipleViolations())->toBeFalse() + ->and($e->getViolationCount())->toBe(1) + ->and($e->getViolations())->toBe(['single failure']) + ->and($e->getMessage())->toBe('single failure') + ->and($e->getMessage())->not->toContain('Multiple invariant violations'); + } +}) + ->group('Unit'); + +test('InvariantViolation::fromViolations should format multiple violations', function () { + $violations = ['First error', 'Second error', 'Third error']; + $exception = InvariantViolation::fromViolations($violations); + + expect($exception)->toBeInstanceOf(InvariantViolation::class) + ->and($exception->hasMultipleViolations())->toBeTrue() + ->and($exception->getViolationCount())->toBe(3) + ->and($exception->getViolations())->toBe($violations) + ->and($exception->getMessage())->toContain('Multiple invariant violations (3)') + ->and($exception->getMessage())->toContain('First error') + ->and($exception->getMessage())->toContain('Second error') + ->and($exception->getMessage())->toContain('Third error'); +}) + ->group('Unit'); + +test('InvariantViolation::fromViolations with single violation should not show count', function () { + $exception = InvariantViolation::fromViolations(['Single error message']); + + expect($exception)->toBeInstanceOf(InvariantViolation::class) + ->and($exception->hasMultipleViolations())->toBeFalse() + ->and($exception->getViolationCount())->toBe(1) + ->and($exception->getViolations())->toBe(['Single error message']) + ->and($exception->getMessage())->toBe('Single error message'); +}) + ->group('Unit'); + +test('Custom non-aggregatable exception should be thrown immediately', function () { + new class () { + use HasInvariants; + + public function __construct() + { + $this->check(); + } + + protected function invariantCustomError(): bool + { + throw new DomainException('Custom domain error'); + } + }; +}) + ->group('Unit') + ->throws(DomainException::class, 'Custom domain error'); + +test('Custom non-aggregatable exception stops invariant checking', function () { + new class () { + use HasInvariants; + + public function __construct() + { + $this->check(); + } + + protected function invariantFirstCheck(): bool + { + // This should throw immediately + throw new RuntimeException('First error'); + } + + protected function invariantSecondCheck(): bool + { + // This should NEVER be reached + throw new DomainException('Second error - should not be reached'); + } + }; +}) + ->group('Unit') + ->throws(RuntimeException::class, 'First error'); + +test('Custom aggregatable exception should be aggregated', function () { + try { + new class () { + use HasInvariants; + + public function __construct() + { + $this->check(); + } + + protected function invariantFirst(): bool + { + // Create aggregatable exception inline + $exception = new class ('Aggregatable error') extends \Exception implements Aggregatable {}; + throw $exception; + } + + protected function invariantSecond(): bool + { + return false; // Regular InvariantViolation + } + }; + } catch (InvariantViolation $e) { + expect($e->hasMultipleViolations())->toBeTrue() + ->and($e->getViolationCount())->toBe(2) + ->and($e->getViolations())->toContain('Aggregatable error') + ->and($e->getViolations())->toContain('second'); + } +}) + ->group('Unit'); + +test('InvariantViolation implements Aggregatable', function () { + $exception = InvariantViolation::fromViolations(['Test']); + + expect($exception)->toBeInstanceOf(Aggregatable::class); +}) + ->group('Unit'); + +test('Mix of custom non-aggregatable throws immediately before aggregation', function () { + new class () { + use HasInvariants; + + public function __construct() + { + $this->check(); + } + + protected function invariantFirstAggregatable(): bool + { + return false; // InvariantViolation + } + + protected function invariantCustomNonAggregatable(): bool + { + throw new RuntimeException('Non-aggregatable error'); + } + + protected function invariantThirdAggregatable(): bool + { + return false; // Should not be reached + } + }; +}) + ->group('Unit') + ->throws(RuntimeException::class, 'Non-aggregatable error'); diff --git a/tests/TypeValidationTest.php b/tests/TypeValidationTest.php index fa4e528..091f486 100644 --- a/tests/TypeValidationTest.php +++ b/tests/TypeValidationTest.php @@ -204,3 +204,39 @@ test('make() should validate union types with named parameters', function () { Money::make(amount: 'invalid', currency: 'USD'); })->throws(TypeError::class, 'parameter "amount" must be of type int|float'); + +test('new() should work as alias for make()', function () { + $email = Email::new('test@example.com'); + + expect($email)->toBeInstanceOf(Email::class) + ->and((string) $email)->toBe('test@example.com'); +}); + +test('new() should support positional parameters', function () { + $money = Money::new(100, 'USD'); + + expect($money)->toBeInstanceOf(Money::class) + ->and((string) $money)->toBe('100 USD'); +}); + +test('new() should support named parameters', function () { + $email = Email::new(value: 'user@example.com'); + + expect($email)->toBeInstanceOf(Email::class) + ->and((string) $email)->toBe('user@example.com'); +}); + +test('new() should support named parameters in any order', function () { + $money = Money::new(currency: 'EUR', amount: 99.99); + + expect($money)->toBeInstanceOf(Money::class) + ->and((string) $money)->toBe('99.99 EUR'); +}); + +test('new() should throw TypeError for invalid types', function () { + Email::new(123); +})->throws(TypeError::class, 'parameter "value" must be of type string, int given'); + +test('new() should validate union types', function () { + Money::new(['invalid'], 'USD'); +})->throws(TypeError::class, 'parameter "amount" must be of type int|float'); diff --git a/tests/ValueObjectsTest.php b/tests/ValueObjectsTest.php index 5446269..7806fbf 100644 --- a/tests/ValueObjectsTest.php +++ b/tests/ValueObjectsTest.php @@ -55,7 +55,7 @@ protected string $_pattern = '[a-z]'; }; }) - ->throws(InvalidArgumentException::class) + ->throws(InvalidArgumentException::class) // Non-aggregatable, thrown immediately ->group('Unit'); test('BooleanValue should create a valid BooleanValue Object.', function () {