Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
18 changes: 7 additions & 11 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -41,13 +41,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": {
Expand Down
4 changes: 4 additions & 0 deletions phpstan.neon
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
parameters:
paths:
- src/
level: 8
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
32 changes: 32 additions & 0 deletions src/Exceptions/Contracts/AggregatesErrors.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

declare(strict_types=1);

namespace ComplexHeart\Domain\Model\Exceptions\Contracts;

use Exception;
use Throwable;

/**
* Interface AggregatesErrors
*
* Marks exceptions/errors that can hold and aggregate multiple error messages.
*
* Exceptions/Errors implementing this interface must provide a static factory method
* to create instances from an array of error messages. This allows the
* invariant system to aggregate multiple errors into a single exception.
*
* @author Unay Santisteban <usantisteban@othercode.io>
*/
interface AggregatesErrors
{
/**
* Create an exception instance from one or more error messages.
*
* @param array<int, Throwable&Aggregatable> $errors
* @param int $code
* @param Exception|null $previous
* @return static
*/
public static function fromErrors(array $errors, int $code = 0, ?Exception $previous = null): static;
}
70 changes: 5 additions & 65 deletions src/Exceptions/InvariantViolation.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 <usantisteban@othercode.io>
* @package ComplexHeart\Domain\Model\Exceptions
*/
class InvariantViolation extends Exception implements Aggregatable
class InvariantViolation extends Exception implements Aggregatable, AggregatesErrors
{
/**
* @var array<int, string> List of all violation messages
*/
private array $violations = [];

/**
* Create an invariant violation exception from one or more violations.
*
* @param array<int, string> $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<int, string>
*/
public function getViolations(): array
{
return $this->violations;
}

/**
* Get the count of violations.
*
* @return int
*/
public function getViolationCount(): int
{
return count($this->violations);
}
use CanAggregateErrors;
}
92 changes: 92 additions & 0 deletions src/Exceptions/Traits/CanAggregateErrors.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
<?php

declare(strict_types=1);

namespace ComplexHeart\Domain\Model\Exceptions\Traits;

use ComplexHeart\Domain\Model\Exceptions\Contracts\Aggregatable;
use Exception;
use Throwable;

use function Lambdish\Phunctional\map;

/**
* Trait CanAggregateErrors
*
* Provides the implementation for exceptions that can aggregate multiple errors.
*
* This trait implements the complete logic for:
* - Creating exceptions from multiple error messages
* - Storing error messages
* - Formatting aggregated messages
* - Providing access to individual errors
*
* Use this trait along with AggregatesErrors interface to create
* exceptions that can hold multiple error messages.
*
* @author Unay Santisteban <usantisteban@othercode.io>
*/
trait CanAggregateErrors
{
/**
* @var array<int, Throwable&Aggregatable>
*/
private array $errors = [];

/**
* Create an exception from one or more error messages.
*
* @param array<int, Throwable&Aggregatable> $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<int, Throwable&Aggregatable>
*/
public function getErrors(): array
{
return $this->errors;
}

/**
* Get the count of errors.
*
* @return int
*/
public function getErrorCount(): int
{
return count($this->errors);
}
}
4 changes: 2 additions & 2 deletions src/Traits/HasDomainEvents.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 15 additions & 8 deletions src/Traits/HasInvariants.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down Expand Up @@ -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<string, Throwable> $violations
* @param string $exception
Expand All @@ -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 = [];
Expand All @@ -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));
}
}
}
5 changes: 3 additions & 2 deletions tests/AggregatesTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading