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(