diff --git a/src/Traits/HasInvariants.php b/src/Traits/HasInvariants.php index b297c47..e10bb22 100644 --- a/src/Traits/HasInvariants.php +++ b/src/Traits/HasInvariants.php @@ -5,7 +5,9 @@ namespace ComplexHeart\Domain\Model\Traits; use ComplexHeart\Domain\Model\Exceptions\InvariantViolation; -use Exception; +use Throwable; + +use function Lambdish\Phunctional\map; /** * Trait HasInvariants @@ -15,6 +17,13 @@ */ trait HasInvariants { + /** + * Static property to keep cached invariants list to optimize performance. + * + * @var array + */ + protected static $_invariantsCache = []; + /** * Retrieve the object invariants. * @@ -22,19 +31,24 @@ trait HasInvariants */ final public static function invariants(): array { - $invariants = []; - foreach (get_class_methods(static::class) as $invariant) { - if (str_starts_with($invariant, 'invariant') && !in_array($invariant, ['invariants', 'invariantHandler'])) { - $invariantRuleName = preg_replace('/[A-Z]([A-Z](?![a-z]))*/', ' $0', $invariant); - if (is_null($invariantRuleName)) { - continue; - } + if (array_key_exists(static::class, static::$_invariantsCache) === false) { + $invariants = []; + foreach (get_class_methods(static::class) as $invariant) { + if (str_starts_with($invariant, 'invariant') && !in_array($invariant, + ['invariants', 'invariantHandler'])) { + $invariantRuleName = preg_replace('/[A-Z]([A-Z](?![a-z]))*/', ' $0', $invariant); + if (is_null($invariantRuleName)) { + continue; + } - $invariants[$invariant] = str_replace('invariant ', '', strtolower($invariantRuleName)); + $invariants[$invariant] = str_replace('invariant ', '', strtolower($invariantRuleName)); + } } + + static::$_invariantsCache[static::class] = $invariants; } - return $invariants; + return static::$_invariantsCache[static::class]; } /** @@ -52,57 +66,68 @@ final public static function invariants(): array * If exception is thrown the error message will be the exception message. * * $onFail function must have the following signature: - * fn(array) => void + * fn(array) => void * * @param string|callable $onFail + * @param string $exception * * @return void */ - private function check(string|callable $onFail = 'invariantHandler'): void - { - $violations = $this->computeInvariantViolations(); + private function check( + string|callable $onFail = 'invariantHandler', + string $exception = InvariantViolation::class + ): void { + $violations = $this->computeInvariantViolations($exception); if (!empty($violations)) { - call_user_func_array($this->computeInvariantHandler($onFail), [$violations]); + call_user_func_array($this->computeInvariantHandler($onFail, $exception), [$violations]); } } /** * Computes the list of invariant violations. * - * @return array + * @param string $exception + * + * @return array */ - private function computeInvariantViolations(): array + private function computeInvariantViolations(string $exception): array { $violations = []; foreach (static::invariants() as $invariant => $rule) { try { if (!$this->{$invariant}()) { - $violations[$invariant] = $rule; + /** @var array $violations */ + $violations[$invariant] = new $exception($rule); } - } catch (Exception $e) { - $violations[$invariant] = $e->getMessage(); + } catch (Throwable $e) { + /** @var array $violations */ + $violations[$invariant] = $e; } } return $violations; } - private function computeInvariantHandler(string|callable $handlerFn): callable + private function computeInvariantHandler(string|callable $handlerFn, string $exception): callable { if (!is_string($handlerFn)) { return $handlerFn; } return method_exists($this, $handlerFn) - ? function (array $violations) use ($handlerFn): void { - $this->{$handlerFn}($violations); + ? function (array $violations) use ($handlerFn, $exception): void { + $this->{$handlerFn}($violations, $exception); } - : function (array $violations): void { - throw new InvariantViolation( + : function (array $violations) use ($exception): void { + if (count($violations) === 1) { + throw array_shift($violations); + } + + throw new $exception( // @phpstan-ignore-line sprintf( - "Unable to create %s due %s", + "Unable to create %s due: %s", basename(str_replace('\\', '/', static::class)), - implode(",", $violations), + implode(", ", map(fn(Throwable $e): string => $e->getMessage(), $violations)), ) ); diff --git a/tests/TraitsTest.php b/tests/TraitsTest.php index 0a041b1..5884e1c 100644 --- a/tests/TraitsTest.php +++ b/tests/TraitsTest.php @@ -3,8 +3,10 @@ declare(strict_types=1); use ComplexHeart\Domain\Model\Errors\ImmutabilityError; +use ComplexHeart\Domain\Model\Exceptions\InvariantViolation; use ComplexHeart\Domain\Model\Test\OrderManagement\Domain\Errors\InvalidPriceError; use ComplexHeart\Domain\Model\Test\OrderManagement\Domain\Price; +use ComplexHeart\Domain\Model\Traits\HasInvariants; test('Object with HasImmutability should throw ImmutabilityError for any update properties attempts.', function () { $price = new Price(100.0, 'EUR'); @@ -33,4 +35,45 @@ new Price(-10.0, 'EURO'); }) ->group('Unit') - ->throws(InvalidPriceError::class); \ No newline at end of file + ->throws(InvalidPriceError::class); + +test('Object with HasInvariants should execute custom invariant handler as closure.', function () { + new class () { + use HasInvariants; + + public function __construct() + { + $this->check(fn(array $violations) => throw new ValueError('From custom Handler')); + } + + protected function invariantAlwaysFail(): bool + { + return false; + } + }; +}) + ->group('Unit') + ->throws(ValueError::class); + +test('Object with HasInvariants should throw exception with list of exceptions', function () { + new class () { + use HasInvariants; + + public function __construct() + { + $this->check(); + } + + protected function invariantAlwaysFailOne(): bool + { + return false; + } + + protected function invariantAlwaysFailTwo(): bool + { + return false; + } + }; +}) + ->group('Unit') + ->throws(InvariantViolation::class, 'always fail one, always fail two'); \ No newline at end of file diff --git a/tests/ValueObjectsTest.php b/tests/ValueObjectsTest.php index cdb823c..b30d078 100644 --- a/tests/ValueObjectsTest.php +++ b/tests/ValueObjectsTest.php @@ -55,7 +55,7 @@ protected string $_pattern = '[a-z]'; }; }) - ->throws(InvariantViolation::class) + ->throws(InvalidArgumentException::class) ->group('Unit'); test('BooleanValue should create a valid BooleanValue Object.', function () { @@ -157,10 +157,10 @@ protected string $valueType = 'string'; }; - expect($vo)->toHaveCount(2); - expect($vo)->toBeIterable(); - expect($vo->getIterator())->toBeInstanceOf(ArrayIterator::class); - expect($vo[0])->toEqual('one'); + expect($vo)->toHaveCount(2) + ->and($vo)->toBeIterable() + ->and($vo->getIterator())->toBeInstanceOf(ArrayIterator::class) + ->and($vo[0])->toEqual('one'); }) ->group('Unit'); @@ -223,8 +223,8 @@ test('UUIDValue should create a valid UUIDValue Object.', function () { $vo = UUIDValue::random(); - expect($vo->is($vo))->toBeTrue(); - expect((string) $vo)->toEqual($vo->__toString()); + expect($vo->is($vo))->toBeTrue() + ->and((string) $vo)->toEqual($vo->__toString()); }) ->group('Unit'); @@ -241,10 +241,9 @@ const TWO = 'two'; }; - expect($vo->value())->toBe('one'); - expect($vo->value())->toBe((string) $vo); - - expect($vo::getLabels()[0])->toBe('ONE'); - expect($vo::getLabels()[1])->toBe('TWO'); + expect($vo->value())->toBe('one') + ->and($vo->value())->toBe((string) $vo) + ->and($vo::getLabels()[0])->toBe('ONE') + ->and($vo::getLabels()[1])->toBe('TWO'); }) ->group('Unit');