From b2ba3fd669dcadd16cf82f3976175e4cce8eb879 Mon Sep 17 00:00:00 2001 From: Unay Santisteban Date: Tue, 14 Oct 2025 17:26:48 +1000 Subject: [PATCH 1/2] Feature/update event contracts and dependencies for improved compatibility --- .github/workflows/test.yml | 7 +++++-- composer.json | 17 +++++++---------- phpstan.neon | 4 ++++ src/Traits/HasDomainEvents.php | 4 ++-- tests/AggregatesTest.php | 5 +++-- .../Domain/Events/OrderCreated.php | 2 +- 6 files changed, 22 insertions(+), 17 deletions(-) create mode 100644 phpstan.neon diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3e99bc6..0aa8b46 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,7 +13,7 @@ jobs: strategy: matrix: - php-version: [ '8.2', '8.3', '8.4'] + php-version: [ '8.2', '8.3', '8.4' ] steps: - name: Checkout source code @@ -40,7 +40,10 @@ jobs: - name: Install Dependencies if: steps.composer-cache.outputs.cache-hit != 'true' - run: composer install --prefer-dist --no-progress --no-suggest + run: composer install --prefer-dist --no-progress + + - name: Check Code Style + run: composer pint-test - name: Execute Static Code analysis run: composer analyse diff --git a/composer.json b/composer.json index 61f98dc..b1750cc 100644 --- a/composer.json +++ b/composer.json @@ -11,19 +11,19 @@ ], "minimum-stability": "stable", "require": { - "php": "^8.1.0", + "php": "^8.2", "ext-json": "*", "ramsey/uuid": "^4.1.0", "nesbot/carbon": "^3.2.0", "illuminate/collections": "^11.0.0", "lambdish/phunctional": "^2.1.0", "doctrine/instantiator": "^2.0.0", - "complex-heart/contracts": "^2.0.0" + "complex-heart/contracts": "^3.0.0" }, "require-dev": { "mockery/mockery": "^1.6.0", - "pestphp/pest": "^2.0", - "pestphp/pest-plugin-faker": "^2.0", + "pestphp/pest": "^3.8.4", + "pestphp/pest-plugin-faker": "^3.0.0", "phpstan/phpstan": "^1.0", "phpstan/extension-installer": "^1.3", "phpstan/phpstan-mockery": "^1.1", @@ -42,12 +42,9 @@ "scripts": { "test": "vendor/bin/pest --configuration=phpunit.xml --coverage --coverage-clover=coverage.xml --log-junit=test.xml", "test-cov": "vendor/bin/pest --configuration=phpunit.xml --coverage --coverage-html=coverage", - "analyse": "vendor/bin/phpstan analyse src --no-progress --memory-limit=4G --level=8", - "check": [ - "@analyse", - "@test" - ], - "pint": "vendor/bin/pint --preset psr12" + "analyse": "vendor/bin/phpstan analyse --no-progress --memory-limit=4G", + "pint-test": "vendor/bin/pint --preset=psr12 --test", + "pint": "vendor/bin/pint --preset=psr12" }, "config": { "allow-plugins": { diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..6267558 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,4 @@ +parameters: + paths: + - src/ + level: 8 diff --git a/src/Traits/HasDomainEvents.php b/src/Traits/HasDomainEvents.php index a797e99..674c48a 100644 --- a/src/Traits/HasDomainEvents.php +++ b/src/Traits/HasDomainEvents.php @@ -4,8 +4,8 @@ namespace ComplexHeart\Domain\Model\Traits; -use ComplexHeart\Domain\Contracts\ServiceBus\Event; -use ComplexHeart\Domain\Contracts\ServiceBus\EventBus; +use ComplexHeart\Domain\Contracts\Events\Event; +use ComplexHeart\Domain\Contracts\Events\EventBus; /** * Trait HasDomainEvents diff --git a/tests/AggregatesTest.php b/tests/AggregatesTest.php index f301a39..346e692 100644 --- a/tests/AggregatesTest.php +++ b/tests/AggregatesTest.php @@ -2,8 +2,9 @@ declare(strict_types=1); -use ComplexHeart\Domain\Contracts\ServiceBus\Event; -use ComplexHeart\Domain\Contracts\ServiceBus\EventBus; + +use ComplexHeart\Domain\Contracts\Events\Event; +use ComplexHeart\Domain\Contracts\Events\EventBus; use ComplexHeart\Domain\Model\Test\Fixtures\OrderManagement\Domain\Order; use ComplexHeart\Domain\Model\ValueObjects\UUIDValue; diff --git a/tests/Fixtures/OrderManagement/Domain/Events/OrderCreated.php b/tests/Fixtures/OrderManagement/Domain/Events/OrderCreated.php index 2b92993..3c5cfd2 100644 --- a/tests/Fixtures/OrderManagement/Domain/Events/OrderCreated.php +++ b/tests/Fixtures/OrderManagement/Domain/Events/OrderCreated.php @@ -4,7 +4,7 @@ namespace ComplexHeart\Domain\Model\Test\Fixtures\OrderManagement\Domain\Events; -use ComplexHeart\Domain\Contracts\ServiceBus\Event; +use ComplexHeart\Domain\Contracts\Events\Event; use ComplexHeart\Domain\Model\Test\Fixtures\OrderManagement\Domain\OrderLine; use ComplexHeart\Domain\Model\Test\Fixtures\OrderManagement\Domain\Order; use ComplexHeart\Domain\Model\ValueObjects\DateTimeValue as Timestamp; From 6f592296933b15ef349f429520105f09839c5850 Mon Sep 17 00:00:00 2001 From: Unay Santisteban Date: Thu, 16 Oct 2025 13:30:34 +1000 Subject: [PATCH 2/2] Add error aggregation support with new interfaces and traits --- composer.json | 1 - .../Contracts/Aggregatable.php | 6 +- src/Exceptions/Contracts/AggregatesErrors.php | 32 +++++++ src/Exceptions/InvariantViolation.php | 70 +------------- src/Exceptions/Traits/CanAggregateErrors.php | 92 +++++++++++++++++++ src/Traits/HasInvariants.php | 23 +++-- tests/HasInvariantsTest.php | 66 ++++++------- 7 files changed, 182 insertions(+), 108 deletions(-) rename src/{ => Exceptions}/Contracts/Aggregatable.php (58%) create mode 100644 src/Exceptions/Contracts/AggregatesErrors.php create mode 100644 src/Exceptions/Traits/CanAggregateErrors.php diff --git a/composer.json b/composer.json index b1750cc..37dd073 100644 --- a/composer.json +++ b/composer.json @@ -41,7 +41,6 @@ }, "scripts": { "test": "vendor/bin/pest --configuration=phpunit.xml --coverage --coverage-clover=coverage.xml --log-junit=test.xml", - "test-cov": "vendor/bin/pest --configuration=phpunit.xml --coverage --coverage-html=coverage", "analyse": "vendor/bin/phpstan analyse --no-progress --memory-limit=4G", "pint-test": "vendor/bin/pint --preset=psr12 --test", "pint": "vendor/bin/pint --preset=psr12" diff --git a/src/Contracts/Aggregatable.php b/src/Exceptions/Contracts/Aggregatable.php similarity index 58% rename from src/Contracts/Aggregatable.php rename to src/Exceptions/Contracts/Aggregatable.php index d734f77..9de6d8f 100644 --- a/src/Contracts/Aggregatable.php +++ b/src/Exceptions/Contracts/Aggregatable.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace ComplexHeart\Domain\Model\Contracts; +namespace ComplexHeart\Domain\Model\Exceptions\Contracts; /** * Interface Aggregatable * - * Marker interface for exceptions that can be aggregated during invariant validation. + * Marker interface for exceptions/errors that can be aggregated during invariant validation. * - * Exceptions implementing this interface will be collected and aggregated when + * Exceptions/Errors implementing this interface will be collected and aggregated when * multiple invariants fail. Exceptions NOT implementing this interface will be * thrown immediately, stopping invariant checking. * diff --git a/src/Exceptions/Contracts/AggregatesErrors.php b/src/Exceptions/Contracts/AggregatesErrors.php new file mode 100644 index 0000000..23d44d8 --- /dev/null +++ b/src/Exceptions/Contracts/AggregatesErrors.php @@ -0,0 +1,32 @@ + + */ +interface AggregatesErrors +{ + /** + * Create an exception instance from one or more error messages. + * + * @param array $errors + * @param int $code + * @param Exception|null $previous + * @return static + */ + public static function fromErrors(array $errors, int $code = 0, ?Exception $previous = null): static; +} diff --git a/src/Exceptions/InvariantViolation.php b/src/Exceptions/InvariantViolation.php index bcedd16..987ca2b 100644 --- a/src/Exceptions/InvariantViolation.php +++ b/src/Exceptions/InvariantViolation.php @@ -4,77 +4,17 @@ namespace ComplexHeart\Domain\Model\Exceptions; -use ComplexHeart\Domain\Model\Contracts\Aggregatable; +use ComplexHeart\Domain\Model\Exceptions\Contracts\Aggregatable; +use ComplexHeart\Domain\Model\Exceptions\Contracts\AggregatesErrors; +use ComplexHeart\Domain\Model\Exceptions\Traits\CanAggregateErrors; use Exception; /** * Class InvariantViolation * * @author Unay Santisteban - * @package ComplexHeart\Domain\Model\Exceptions */ -class InvariantViolation extends Exception implements Aggregatable +class InvariantViolation extends Exception implements Aggregatable, AggregatesErrors { - /** - * @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); - } + use CanAggregateErrors; } diff --git a/src/Exceptions/Traits/CanAggregateErrors.php b/src/Exceptions/Traits/CanAggregateErrors.php new file mode 100644 index 0000000..fd04e8c --- /dev/null +++ b/src/Exceptions/Traits/CanAggregateErrors.php @@ -0,0 +1,92 @@ + + */ +trait CanAggregateErrors +{ + /** + * @var array + */ + private array $errors = []; + + /** + * Create an exception from one or more error messages. + * + * @param array $errors + * @param int $code + * @param Exception|null $previous + * @return static + */ + public static function fromErrors(array $errors, int $code = 0, ?Exception $previous = null): static + { + $messages = map(fn (Throwable $e): string => $e->getMessage(), $errors); + + $count = count($messages); + + // Format message based on count + if ($count === 1) { + $message = $messages[0]; + } else { + $message = sprintf("Multiple errors (%d):\n- %s", $count, implode("\n- ", $messages)); + } + + // @phpstan-ignore-next-line (Safe usage - trait designed for Exception classes with standard constructor) + $exception = new static($message, $code, $previous); + $exception->errors = $errors; + return $exception; + } + + /** + * Check if this exception has multiple errors. + * + * @return bool + */ + public function hasMultipleErrors(): bool + { + return count($this->errors) > 1; + } + + /** + * Get all error messages. + * + * @return array + */ + public function getErrors(): array + { + return $this->errors; + } + + /** + * Get the count of errors. + * + * @return int + */ + public function getErrorCount(): int + { + return count($this->errors); + } +} diff --git a/src/Traits/HasInvariants.php b/src/Traits/HasInvariants.php index d019090..1aac030 100644 --- a/src/Traits/HasInvariants.php +++ b/src/Traits/HasInvariants.php @@ -4,12 +4,11 @@ namespace ComplexHeart\Domain\Model\Traits; -use ComplexHeart\Domain\Model\Contracts\Aggregatable; +use ComplexHeart\Domain\Model\Exceptions\Contracts\Aggregatable; +use ComplexHeart\Domain\Model\Exceptions\Contracts\AggregatesErrors; use ComplexHeart\Domain\Model\Exceptions\InvariantViolation; use Throwable; -use function Lambdish\Phunctional\map; - /** * Trait HasInvariants * @@ -139,8 +138,9 @@ private function computeInvariantHandler(string|callable $handlerFn, string $exc * 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 + * - If $exception class doesn't support aggregation: throw first violation immediately + * - If individual violations are non-aggregatable: throw the first one immediately + * - If all conditions pass: aggregate violations using $exception class * * @param array $violations * @param string $exception @@ -149,6 +149,12 @@ private function computeInvariantHandler(string|callable $handlerFn, string $exc */ private function throwInvariantViolations(array $violations, string $exception): void { + // Early check: Does the exception class support aggregation? + if (!is_subclass_of($exception, AggregatesErrors::class)) { + // @phpstan-ignore-next-line (array_shift always returns Throwable from non-empty violations array) + throw array_shift($violations); + } + // Separate aggregatable from non-aggregatable violations $aggregatable = []; $nonAggregatable = []; @@ -163,13 +169,14 @@ private function throwInvariantViolations(array $violations, string $exception): // If there are non-aggregatable exceptions, throw the first one immediately if (!empty($nonAggregatable)) { + // @phpstan-ignore-next-line (array_shift always returns Throwable from non-empty array) throw array_shift($nonAggregatable); } - // All violations are aggregatable - aggregate them + // All violations are aggregatable - aggregate them using the provided exception class if (!empty($aggregatable)) { - $messages = map(fn (Throwable $e): string => $e->getMessage(), $aggregatable); - throw InvariantViolation::fromViolations(array_values($messages)); + // @phpstan-ignore-next-line (fromErrors returns Throwable instance implementing AggregatesErrors) + throw $exception::fromErrors(array_values($aggregatable)); } } } diff --git a/tests/HasInvariantsTest.php b/tests/HasInvariantsTest.php index 7a47bbb..0731bbb 100644 --- a/tests/HasInvariantsTest.php +++ b/tests/HasInvariantsTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -use ComplexHeart\Domain\Model\Contracts\Aggregatable; +use ComplexHeart\Domain\Model\Exceptions\Contracts\Aggregatable; use ComplexHeart\Domain\Model\Exceptions\InvariantViolation; use ComplexHeart\Domain\Model\Test\Fixtures\OrderManagement\Domain\Errors\InvalidPriceError; use ComplexHeart\Domain\Model\Test\Fixtures\OrderManagement\Domain\Price; @@ -57,7 +57,7 @@ protected function invariantAlwaysFailTwo(): bool }; }) ->group('Unit') - ->throws(InvariantViolation::class, 'Multiple invariant violations (2)'); + ->throws(InvariantViolation::class, 'Multiple errors (2)'); test('InvariantViolation should support multiple violations aggregation', function () { try { @@ -85,18 +85,18 @@ protected function invariantAlwaysFailThree(): bool } }; } 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)'); + expect($e->hasMultipleErrors())->toBeTrue() + ->and($e->getErrorCount())->toBe(3) + ->and($e->getErrors())->toHaveCount(3) + ->and($e->getMessage())->toContain('Multiple errors (3)') + ->and($e->getErrors()[0]->getMessage())->toContain('always fail one') + ->and($e->getErrors()[1]->getMessage())->toContain('always fail two') + ->and($e->getErrors()[2]->getMessage())->toContain('always fail three'); } }) ->group('Unit'); -test('InvariantViolation::fromViolations should handle single violation cleanly', function () { +test('InvariantViolation::fromErrors should handle single error cleanly', function () { try { new class () { use HasInvariants; @@ -112,37 +112,41 @@ protected function invariantSingleFailure(): bool } }; } catch (InvariantViolation $e) { - expect($e->hasMultipleViolations())->toBeFalse() - ->and($e->getViolationCount())->toBe(1) - ->and($e->getViolations())->toBe(['single failure']) + expect($e->hasMultipleErrors())->toBeFalse() + ->and($e->getErrorCount())->toBe(1) + ->and($e->getErrors()[0]->getMessage())->toBe('single failure') ->and($e->getMessage())->toBe('single failure') - ->and($e->getMessage())->not->toContain('Multiple invariant violations'); + ->and($e->getMessage())->not->toContain('Multiple errors'); } }) ->group('Unit'); -test('InvariantViolation::fromViolations should format multiple violations', function () { - $violations = ['First error', 'Second error', 'Third error']; - $exception = InvariantViolation::fromViolations($violations); +test('InvariantViolation::fromErrors should format multiple errors', function () { + $errors = [ + new InvariantViolation('First error'), + new InvariantViolation('Second error'), + new InvariantViolation('Third error') + ]; + $exception = InvariantViolation::fromErrors($errors); 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->hasMultipleErrors())->toBeTrue() + ->and($exception->getErrorCount())->toBe(3) + ->and($exception->getErrors())->toBe($errors) + ->and($exception->getMessage())->toContain('Multiple errors (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']); +test('InvariantViolation::fromErrors with single error should not show count', function () { + $exception = InvariantViolation::fromErrors([new InvariantViolation('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->hasMultipleErrors())->toBeFalse() + ->and($exception->getErrorCount())->toBe(1) + ->and($exception->getErrors()[0]->getMessage())->toBe('Single error message') ->and($exception->getMessage())->toBe('Single error message'); }) ->group('Unit'); @@ -213,16 +217,16 @@ protected function invariantSecond(): bool } }; } catch (InvariantViolation $e) { - expect($e->hasMultipleViolations())->toBeTrue() - ->and($e->getViolationCount())->toBe(2) - ->and($e->getViolations())->toContain('Aggregatable error') - ->and($e->getViolations())->toContain('second'); + expect($e->hasMultipleErrors())->toBeTrue() + ->and($e->getErrorCount())->toBe(2) + ->and($e->getErrors()[0]->getMessage())->toContain('Aggregatable error') + ->and($e->getErrors()[1]->getMessage())->toContain('second'); } }) ->group('Unit'); test('InvariantViolation implements Aggregatable', function () { - $exception = InvariantViolation::fromViolations(['Test']); + $exception = InvariantViolation::fromErrors([new InvariantViolation('Test')]); expect($exception)->toBeInstanceOf(Aggregatable::class); })