diff --git a/CHANGELOG.md b/CHANGELOG.md index c2d2048..3c64cba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,21 @@ All notable changes to this project will be documented in this file. This projec ## Unreleased +### Added + +- New methods on the `ListOfErrors` interface: + - `find()` to find the first matching error. + - `sole()` to get the first error, but only if exactly one error exists. + - `any()` to determine if any of the errors match. + - `every()` to determine if every error matches. + - `filter()` to get a new list containing only matching errors. + +### Deprecated + +- The `ListOfErrors::contains()` method is deprecated and will be removed in 6.0. Use the new `any()` method instead. +- Calling the `ListOfErrors::first()` method with arguments is deprecated and will be removed in 6.0. Use the new + `find()` method instead. + ## [5.0.0-rc.1] - 2025-10-09 ### Added diff --git a/deptrac.yaml b/deptrac.yaml index 1fd0118..2287983 100644 --- a/deptrac.yaml +++ b/deptrac.yaml @@ -38,14 +38,18 @@ deptrac: value: ^Deprecated$ ruleset: Toolkit: + - Attributes Domain: - Toolkit + - Attributes Application: - Toolkit - Domain - PSR Log + - Attributes Infrastructure: - Toolkit - Domain - Application - PSR Log + - Attributes diff --git a/phpunit.xml b/phpunit.xml index 3874214..2968136 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -11,7 +11,7 @@ cacheDirectory=".phpunit.cache" backupStaticProperties="false" failOnWarning="true" - failOnDeprecation="true" + failOnDeprecation="false" failOnNotice="true" > diff --git a/src/Contracts/Toolkit/Result/ListOfErrors.php b/src/Contracts/Toolkit/Result/ListOfErrors.php index 240b9a7..119bd67 100644 --- a/src/Contracts/Toolkit/Result/ListOfErrors.php +++ b/src/Contracts/Toolkit/Result/ListOfErrors.php @@ -24,17 +24,51 @@ interface ListOfErrors extends ListIterator /** * Get the first error in the list, or the first matching error. * - * @param Closure(Error): bool|UnitEnum|null $matcher + * @param (Closure(Error): bool)|UnitEnum|null $matcher */ public function first(Closure|UnitEnum|null $matcher = null): ?Error; + /** + * Find the first matching error in the list. + * + * @param (Closure(Error): bool)|UnitEnum $matcher + */ + public function find(Closure|UnitEnum $matcher): ?Error; + + /** + * Get the first error in the list, but only if exactly one error exists. Otherwise, throw an exception. + * + * @param (Closure(Error): bool)|UnitEnum|null $matcher + */ + public function sole(Closure|UnitEnum|null $matcher = null): Error; + /** * Does the list contain a matching error? * - * @param Closure(Error): bool|UnitEnum $matcher + * @param (Closure(Error): bool)|UnitEnum $matcher + * @deprecated 6.0 use any() instead. */ public function contains(Closure|UnitEnum $matcher): bool; + /** + * Does the list contain at least one matching error? + * + * @param (Closure(Error): bool)|UnitEnum $matcher + */ + public function any(Closure|UnitEnum $matcher): bool; + + /** + * Do all errors in the list match the provided matcher? + * + * @param (Closure(Error): bool)|UnitEnum $matcher + */ + public function every(Closure|UnitEnum $matcher): bool; + + /** + * @param (Closure(Error): bool)|UnitEnum $matcher + */ + public function filter(Closure|UnitEnum $matcher): self; + /** * Get all the unique error codes in the list. * diff --git a/src/Contracts/Toolkit/Result/Result.php b/src/Contracts/Toolkit/Result/Result.php index 38424a0..2699447 100644 --- a/src/Contracts/Toolkit/Result/Result.php +++ b/src/Contracts/Toolkit/Result/Result.php @@ -21,8 +21,14 @@ */ interface Result { + /** + * Is the result a success? + */ public function didSucceed(): bool; + /** + * Is the result a failure? + */ public function didFail(): bool; /** diff --git a/src/Testing/FakeDomainEventDispatcher.php b/src/Testing/FakeDomainEventDispatcher.php index 4eda573..56fad14 100644 --- a/src/Testing/FakeDomainEventDispatcher.php +++ b/src/Testing/FakeDomainEventDispatcher.php @@ -35,7 +35,7 @@ public function count(): int } /** - * Expect a single event to be dispatched and return it. + * Get the first event in the list, but only if exactly one event exists. Otherwise, throw an exception. */ public function sole(): DomainEvent { diff --git a/src/Toolkit/Contracts.php b/src/Toolkit/Contracts.php index 4096659..d341197 100644 --- a/src/Toolkit/Contracts.php +++ b/src/Toolkit/Contracts.php @@ -19,7 +19,7 @@ final class Contracts /** * Assert that the provided precondition is true. * - * @param Closure(): string|string $message + * @param (Closure(): string)|string $message * @phpstan-assert true $precondition */ public static function assert(bool $precondition, Closure|string $message = ''): void diff --git a/src/Toolkit/Result/ListOfErrors.php b/src/Toolkit/Result/ListOfErrors.php index 210ab79..3a8bf09 100644 --- a/src/Toolkit/Result/ListOfErrors.php +++ b/src/Toolkit/Result/ListOfErrors.php @@ -16,8 +16,12 @@ use CloudCreativity\Modules\Contracts\Toolkit\Result\Error as IError; use CloudCreativity\Modules\Contracts\Toolkit\Result\ListOfErrors as IListOfErrors; use CloudCreativity\Modules\Toolkit\Iterables\IsList; +use Deprecated; +use LogicException; use UnitEnum; +use function CloudCreativity\Modules\Toolkit\enum_string; + final class ListOfErrors implements IListOfErrors { /** @use IsList */ @@ -48,34 +52,63 @@ public function first(Closure|UnitEnum|null $matcher = null): ?IError return $this->stack[0] ?? null; } - if ($matcher instanceof UnitEnum) { - $matcher = static fn (IError $error): bool => $error->is($matcher); - } + trigger_error( + 'Calling first() with a matcher is deprecated and will be removed in 6.0; use find() instead.', + E_USER_DEPRECATED, + ); - foreach ($this->stack as $error) { - if ($matcher($error)) { - return $error; - } + return $this->find($matcher); + } + + public function find(Closure|UnitEnum $matcher): ?IError + { + return array_find($this->stack, $this->where($matcher)); + } + + + public function sole(Closure|UnitEnum|null $matcher = null): IError + { + $errors = $matcher ? $this->filter($matcher) : $this; + + if (count($errors->stack) === 1) { + return $errors->stack[0]; } - return null; + throw new LogicException(sprintf( + 'Expected exactly one %s but there are %d.', + match (true) { + $matcher instanceof UnitEnum => sprintf('error with code "%s"', enum_string($matcher)), + $matcher instanceof Closure => 'error matching the criteria', + default => 'error', + }, + count($errors->stack), + )); } + #[Deprecated(message: 'use any() instead', since: '5.0.0-rc.2')] public function contains(Closure|UnitEnum $matcher): bool { - if ($matcher instanceof UnitEnum) { - $matcher = static fn (IError $error): bool => $error->is($matcher); - } + return $this->any($matcher); + } - foreach ($this->stack as $error) { - if ($matcher($error)) { - return true; - } - } + public function any(Closure|UnitEnum $matcher): bool + { + return array_any($this->stack, $this->where($matcher)); + } - return false; + public function every(Closure|UnitEnum $matcher): bool + { + return array_all($this->stack, $this->where($matcher)); } + public function filter(Closure|UnitEnum $matcher): self + { + return new self(...array_values( + array_filter($this->stack, $this->where($matcher)), + )); + } + + public function codes(): array { $codes = []; @@ -153,4 +186,17 @@ public function toKeyedSet(): KeyedSetOfErrors { return new KeyedSetOfErrors(...$this->stack); } + + /** + * @param (Closure(IError): bool)|UnitEnum $matcher + * @return Closure(IError): bool + */ + private function where(Closure|UnitEnum $matcher): Closure + { + if ($matcher instanceof UnitEnum) { + return static fn (IError $error): bool => $error->is($matcher); + } + + return $matcher; + } } diff --git a/tests/Unit/Toolkit/Result/ListOfErrorsTest.php b/tests/Unit/Toolkit/Result/ListOfErrorsTest.php index efb83bf..3e55b25 100644 --- a/tests/Unit/Toolkit/Result/ListOfErrorsTest.php +++ b/tests/Unit/Toolkit/Result/ListOfErrorsTest.php @@ -90,25 +90,51 @@ public function testFirst(): void $errors = new ListOfErrors( $a = new Error(null, 'Message A'), new Error(null, 'Message B'), + new Error(null, 'Message C'), + ); + + $this->assertSame($a, $errors->first()); + } + + public function testFirstWithMatcher(): void + { + $errors = new ListOfErrors( + new Error(null, 'Message A'), + new Error(null, 'Message B'), $c = new Error(null, 'Message C'), new Error(null, 'Message D'), $e = new Error(code: TestUnitEnum::Bat), ); - $this->assertSame($a, $errors->first()); $this->assertSame($c, $errors->first(fn (IError $error) => 'Message C' === $error->message())); $this->assertSame($e, $errors->first(TestUnitEnum::Bat)); $this->assertNull($errors->first(fn (IError $error) => 'Message E' === $error->message())); $this->assertNull($errors->first(TestUnitEnum::Baz)); } + public function testFind(): void + { + $errors = new ListOfErrors( + new Error(null, 'Message A'), + new Error(null, 'Message B'), + $c = new Error(null, 'Message C'), + new Error(null, 'Message D'), + $e = new Error(code: TestUnitEnum::Bat), + ); + + $this->assertSame($c, $errors->find(fn (IError $error) => 'Message C' === $error->message())); + $this->assertSame($e, $errors->find(TestUnitEnum::Bat)); + $this->assertNull($errors->find(fn (IError $error) => 'Message E' === $error->message())); + $this->assertNull($errors->find(TestUnitEnum::Baz)); + } + public function testContains(): void { $errors = new ListOfErrors( new Error(message: 'Message A'), new Error(message: 'Message B'), new Error(message: 'Message C'), - new Error(message: 'Message D', code: TestUnitEnum::Baz), + new Error(code: TestUnitEnum::Baz, message: 'Message D'), ); $this->assertTrue($errors->contains(fn (IError $error) => 'Message C' === $error->message())); @@ -117,13 +143,173 @@ public function testContains(): void $this->assertFalse($errors->contains(TestUnitEnum::Bat)); } + public function testAny(): void + { + $errors = new ListOfErrors( + new Error(message: 'Message A'), + new Error(message: 'Message B'), + new Error(message: 'Message C'), + new Error(code: TestUnitEnum::Baz, message: 'Message D'), + ); + + $this->assertTrue($errors->any(fn (IError $error) => 'Message C' === $error->message())); + $this->assertTrue($errors->any(TestUnitEnum::Baz)); + $this->assertFalse($errors->any(fn (IError $error) => 'Message E' === $error->message())); + $this->assertFalse($errors->any(TestUnitEnum::Bat)); + } + + public function testEvery(): void + { + $errors1 = new ListOfErrors( + new Error(message: 'Message A'), + new Error(message: 'Message B'), + new Error(message: 'Message C'), + new Error(message: 'Message D', code: TestUnitEnum::Baz), + ); + + $errors2 = new ListOfErrors( + new Error(code: TestUnitEnum::Baz, message: 'Message D'), + new Error(code: TestUnitEnum::Baz, message: 'Message D'), + new Error(code: TestUnitEnum::Baz, message: 'Message D'), + ); + + $this->assertTrue($errors1->every(fn (IError $error) => str_starts_with($error->message(), 'Message'))); + $this->assertFalse($errors1->every(fn (IError $error) => null !== $error->code())); + $this->assertFalse($errors1->every(fn (IError $error) => 'Message A' === $error->message())); + + $this->assertTrue($errors2->every(TestUnitEnum::Baz)); + $this->assertFalse($errors1->every(TestUnitEnum::Baz)); + $this->assertFalse($errors2->every(TestUnitEnum::Bat)); + } + + public function testSoleWithExactlyOne(): void + { + $errors1 = new ListOfErrors( + $expected = new Error(message: 'Message A', code: $code = TestUnitEnum::Baz), + ); + + $errors2 = new ListOfErrors( + new Error(message: 'Message B'), + new Error(message: 'Message C'), + $expected, + ); + + $fn = fn (IError $error) => 'Message A' === $error->message(); + + $this->assertSame($expected, $errors1->sole()); + $this->assertSame($expected, $errors1->sole($code)); + $this->assertSame($expected, $errors1->sole($fn)); + + $this->assertSame($expected, $errors2->sole($code)); + $this->assertSame($expected, $errors2->sole($fn)); + } + + public function testSoleWithNone(): void + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Expected exactly one error but there are 0.'); + + $errors = new ListOfErrors(); + + $errors->sole(); + } + + public function testSoleWithMany(): void + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Expected exactly one error but there are 3.'); + + $errors = new ListOfErrors( + new Error(message: 'Message A'), + new Error(message: 'Message B'), + new Error(message: 'Message C'), + ); + + $errors->sole(); + } + + public function testSoleWithNoneMatching(): void + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Expected exactly one error matching the criteria but there are 0.'); + + $errors = new ListOfErrors( + new Error(message: 'Message A'), + new Error(message: 'Message B'), + new Error(message: 'Message C'), + ); + + $errors->sole(fn (IError $error) => 'Message D' === $error->message()); + } + + public function testSoleWithManyMatching(): void + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Expected exactly one error matching the criteria but there are 2.'); + + $errors = new ListOfErrors( + new Error(message: 'Message A'), + new Error(message: 'Message B'), + new Error(message: 'Message A'), + ); + + $errors->sole(fn (IError $error) => 'Message A' === $error->message()); + } + + public function testSoleWithNoneMatchingEnum(): void + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Expected exactly one error with code "Baz" but there are 0.'); + + $errors = new ListOfErrors( + new Error(code: TestUnitEnum::Bat, message: 'Message A'), + new Error(code: TestUnitEnum::Bat, message: 'Message B'), + new Error(code: TestUnitEnum::Bat, message: 'Message C'), + ); + + $errors->sole(TestUnitEnum::Baz); + } + + public function testSoleWithManyMatchingEnum(): void + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Expected exactly one error with code "Bat" but there are 2.'); + + $errors = new ListOfErrors( + new Error(code: TestUnitEnum::Bat, message: 'Message A'), + new Error(code: TestUnitEnum::Bat, message: 'Message B'), + new Error(code: TestUnitEnum::Baz, message: 'Message C'), + ); + + $errors->sole(TestUnitEnum::Bat); + } + + public function testFilter(): void + { + $errors = new ListOfErrors( + new Error(message: 'Message A'), + $b = new Error(code: TestUnitEnum::Bat, message: 'Message B'), + $c = new Error(code: TestUnitEnum::Bat, message: 'Message C'), + $d = new Error(code: TestUnitEnum::Baz, message: 'Message D'), + ); + + $filtered1 = $errors->filter(fn (IError $error) => in_array($error, [$b, $d], true)); + $filtered2 = $errors->filter(TestUnitEnum::Bat); + + $this->assertCount(4, $errors); + $this->assertSame([$b, $d], $filtered1->all()); + $this->assertSame([$b, $c], $filtered2->filter(TestUnitEnum::Bat)->all()); + $this->assertEmpty($errors->filter(fn (IError $error) => $error->message() === 'Message E')); + $this->assertEmpty($errors->filter(TestBackedEnum::Bar)); + } + public function testCodes(): void { $errors1 = new ListOfErrors( new Error(message: 'Message A'), - new Error(message: 'Message B', code: TestBackedEnum::Foo), - new Error(message: 'Message C', code: TestUnitEnum::Baz), - new Error(message: 'Message D', code: TestBackedEnum::Foo), + new Error(code: TestBackedEnum::Foo, message: 'Message B'), + new Error(code: TestUnitEnum::Baz, message: 'Message C'), + new Error(code: TestBackedEnum::Foo, message: 'Message D'), ); $errors2 = new ListOfErrors( @@ -139,9 +325,9 @@ public function testCode(): void { $errors1 = new ListOfErrors( new Error(message: 'Message A'), - new Error(message: 'Message B', code: TestBackedEnum::Foo), - new Error(message: 'Message C', code: TestUnitEnum::Baz), - new Error(message: 'Message D', code: TestBackedEnum::Foo), + new Error(code: TestBackedEnum::Foo, message: 'Message B'), + new Error(code: TestUnitEnum::Baz, message: 'Message C'), + new Error(code: TestBackedEnum::Foo, message: 'Message D'), ); $errors2 = new ListOfErrors(