Skip to content

Commit cad45c9

Browse files
committed
Merge branch 'release/5.0.0-rc.3'
2 parents bdfbfc1 + 9c93742 commit cad45c9

File tree

10 files changed

+351
-18
lines changed

10 files changed

+351
-18
lines changed

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file. This projec
55

66
## Unreleased
77

8+
## [5.0.0-rc.3] - 2025-10-14
9+
10+
### Added
11+
12+
- **BREAKING** Command and query validators can now return early on the first validation failure, by marking the
13+
validator with the `stopOnFirstFailure()` method. This is technically a breaking change as the method was added to the
14+
validator interface; however, if you are using the `Validator` class provided by this package, it now implements this
15+
method.
16+
- Middleware that extend `ValidateCommand` and `ValidateQuery` can mark themselves as stopping on the first failure by
17+
implementing the `Bail` interface, or overloading the `stopOnFirstFailure()` method to return `true`.
18+
819
## [5.0.0-rc.2] - 2025-10-14
920

1021
### Added

src/Application/Bus/Middleware/ValidateCommand.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
namespace CloudCreativity\Modules\Application\Bus\Middleware;
1414

1515
use Closure;
16+
use CloudCreativity\Modules\Contracts\Application\Bus\Bail;
1617
use CloudCreativity\Modules\Contracts\Application\Bus\CommandMiddleware;
1718
use CloudCreativity\Modules\Contracts\Application\Bus\Validator;
1819
use CloudCreativity\Modules\Contracts\Toolkit\Messages\Command;
@@ -36,6 +37,7 @@ public function __invoke(Command $command, Closure $next): IResult
3637
{
3738
$errors = $this->validator
3839
->using($this->rules())
40+
->stopOnFirstFailure($this->stopOnFirstFailure($command))
3941
->validate($command);
4042

4143
if ($errors->isNotEmpty()) {
@@ -44,4 +46,9 @@ public function __invoke(Command $command, Closure $next): IResult
4446

4547
return $next($command);
4648
}
49+
50+
protected function stopOnFirstFailure(Command $command): bool
51+
{
52+
return $this instanceof Bail;
53+
}
4754
}

src/Application/Bus/Middleware/ValidateQuery.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
namespace CloudCreativity\Modules\Application\Bus\Middleware;
1414

1515
use Closure;
16+
use CloudCreativity\Modules\Contracts\Application\Bus\Bail;
1617
use CloudCreativity\Modules\Contracts\Application\Bus\QueryMiddleware;
1718
use CloudCreativity\Modules\Contracts\Application\Bus\Validator;
1819
use CloudCreativity\Modules\Contracts\Toolkit\Messages\Query;
@@ -36,6 +37,7 @@ public function __invoke(Query $query, Closure $next): IResult
3637
{
3738
$errors = $this->validator
3839
->using($this->rules())
40+
->stopOnFirstFailure($this->stopOnFirstFailure($query))
3941
->validate($query);
4042

4143
if ($errors->isNotEmpty()) {
@@ -44,4 +46,9 @@ public function __invoke(Query $query, Closure $next): IResult
4446

4547
return $next($query);
4648
}
49+
50+
protected function stopOnFirstFailure(Query $query): bool
51+
{
52+
return $this instanceof Bail;
53+
}
4754
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
<?php
2+
3+
/*
4+
* Copyright 2025 Cloud Creativity Limited
5+
*
6+
* Use of this source code is governed by an MIT-style
7+
* license that can be found in the LICENSE file or at
8+
* https://opensource.org/licenses/MIT.
9+
*/
10+
11+
declare(strict_types=1);
12+
13+
namespace CloudCreativity\Modules\Application\Bus;
14+
15+
use CloudCreativity\Modules\Contracts\Toolkit\Messages\Command;
16+
use CloudCreativity\Modules\Contracts\Toolkit\Messages\Query;
17+
use CloudCreativity\Modules\Contracts\Toolkit\Pipeline\Processor;
18+
use CloudCreativity\Modules\Toolkit\Result\ListOfErrors;
19+
20+
final readonly class ValidationProcessor implements Processor
21+
{
22+
public function __construct(private bool $stopOnFirstFailure = false)
23+
{
24+
}
25+
26+
/**
27+
* @param (callable(Command|Query): ?ListOfErrors) ...$stages
28+
*/
29+
public function process(mixed $payload, callable ...$stages): ListOfErrors
30+
{
31+
assert($payload instanceof Command || $payload instanceof Query);
32+
33+
$errors = new ListOfErrors();
34+
35+
foreach ($stages as $stage) {
36+
$result = $stage($payload);
37+
38+
if ($result) {
39+
$errors = $errors->merge($result);
40+
}
41+
42+
if ($this->stopOnFirstFailure && $errors->isNotEmpty()) {
43+
return $errors;
44+
}
45+
}
46+
47+
return $errors;
48+
}
49+
}

src/Application/Bus/Validator.php

Lines changed: 10 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@
1818
use CloudCreativity\Modules\Contracts\Toolkit\Pipeline\PipeContainer;
1919
use CloudCreativity\Modules\Contracts\Toolkit\Pipeline\Pipeline;
2020
use CloudCreativity\Modules\Contracts\Toolkit\Result\ListOfErrors as IListOfErrors;
21-
use CloudCreativity\Modules\Toolkit\Pipeline\AccumulationProcessor;
2221
use CloudCreativity\Modules\Toolkit\Pipeline\PipelineBuilder;
2322
use CloudCreativity\Modules\Toolkit\Result\ListOfErrors;
2423

@@ -29,21 +28,26 @@ final class Validator implements IValidator
2928
*/
3029
private iterable $using = [];
3130

31+
private bool $stopOnFirstFailure = false;
32+
3233
public function __construct(private readonly ?PipeContainer $rules = null)
3334
{
3435
}
3536

36-
/**
37-
* @param iterable<callable|string> $rules
38-
* @return $this
39-
*/
4037
public function using(iterable $rules): static
4138
{
4239
$this->using = $rules;
4340

4441
return $this;
4542
}
4643

44+
public function stopOnFirstFailure(bool $stop = true): static
45+
{
46+
$this->stopOnFirstFailure = $stop;
47+
48+
return $this;
49+
}
50+
4751
public function validate(Command|Query $message): IListOfErrors
4852
{
4953
$errors = $this
@@ -59,16 +63,6 @@ private function getPipeline(): Pipeline
5963
{
6064
return PipelineBuilder::make($this->rules)
6165
->through($this->using)
62-
->build($this->processor());
63-
}
64-
65-
private function processor(): AccumulationProcessor
66-
{
67-
return new AccumulationProcessor(
68-
static function (?IListOfErrors $carry, ?IListOfErrors $errors): IListOfErrors {
69-
$errors ??= new ListOfErrors();
70-
return $carry ? $carry->merge($errors) : $errors;
71-
},
72-
);
66+
->build(new ValidationProcessor($this->stopOnFirstFailure));
7367
}
7468
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<?php
2+
3+
/*
4+
* Copyright 2025 Cloud Creativity Limited
5+
*
6+
* Use of this source code is governed by an MIT-style
7+
* license that can be found in the LICENSE file or at
8+
* https://opensource.org/licenses/MIT.
9+
*/
10+
11+
declare(strict_types=1);
12+
13+
namespace CloudCreativity\Modules\Contracts\Application\Bus;
14+
15+
interface Bail
16+
{
17+
}

src/Contracts/Application/Bus/Validator.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,13 @@ interface Validator
2626
*/
2727
public function using(iterable $rules): static;
2828

29+
/**
30+
* Stop validating as soon as the first rule fails.
31+
*
32+
* @return $this
33+
*/
34+
public function stopOnFirstFailure(bool $stop = true): static;
35+
2936
/**
3037
* Validate the provided message.
3138
*/

tests/Unit/Application/Bus/Middleware/ValidateCommandTest.php

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
namespace CloudCreativity\Modules\Tests\Unit\Application\Bus\Middleware;
1414

1515
use CloudCreativity\Modules\Application\Bus\Middleware\ValidateCommand;
16+
use CloudCreativity\Modules\Contracts\Application\Bus\Bail;
1617
use CloudCreativity\Modules\Contracts\Application\Bus\Validator;
1718
use CloudCreativity\Modules\Contracts\Toolkit\Messages\Command;
1819
use CloudCreativity\Modules\Contracts\Toolkit\Result\Result;
@@ -62,6 +63,11 @@ public function testItSucceeds(): void
6263
}))
6364
->willReturnSelf();
6465

66+
$this->validator
67+
->expects($this->once())
68+
->method('stopOnFirstFailure')
69+
->willReturnSelf();
70+
6571
$this->validator
6672
->expects($this->once())
6773
->method('validate')
@@ -85,9 +91,55 @@ public function testItSucceeds(): void
8591
public function testItFails(): void
8692
{
8793
$this->validator
94+
->expects($this->once())
8895
->method('using')
8996
->willReturnSelf();
9097

98+
$this->validator
99+
->expects($this->once())
100+
->method('stopOnFirstFailure')
101+
->with(false)
102+
->willReturnSelf();
103+
104+
$this->validator
105+
->expects($this->once())
106+
->method('validate')
107+
->with($command = $this->createMock(Command::class))
108+
->willReturn($errors = new ListOfErrors(new Error(null, 'Something went wrong.')));
109+
110+
$next = function () {
111+
throw new \LogicException('Not expecting next closure to be called.');
112+
};
113+
114+
$result = ($this->middleware)($command, $next);
115+
116+
$this->assertTrue($result->didFail());
117+
$this->assertSame($errors, $result->errors());
118+
}
119+
120+
public function testItStopsOnFirstFailureViaBail(): void
121+
{
122+
$this->middleware = new class ($this->validator) extends ValidateCommand implements Bail {
123+
/**
124+
* @return iterable<string>
125+
*/
126+
protected function rules(): iterable
127+
{
128+
return ['foo', 'bar'];
129+
}
130+
};
131+
132+
$this->validator
133+
->expects($this->once())
134+
->method('using')
135+
->willReturnSelf();
136+
137+
$this->validator
138+
->expects($this->once())
139+
->method('stopOnFirstFailure')
140+
->with(true)
141+
->willReturnSelf();
142+
91143
$this->validator
92144
->expects($this->once())
93145
->method('validate')
@@ -103,4 +155,55 @@ public function testItFails(): void
103155
$this->assertTrue($result->didFail());
104156
$this->assertSame($errors, $result->errors());
105157
}
158+
159+
public function testItStopsOnFirstFailure(): void
160+
{
161+
$command = $this->createMock(Command::class);
162+
163+
$this->middleware = new class ($command, $this->validator) extends ValidateCommand {
164+
public function __construct(private Command $command, Validator $validator)
165+
{
166+
parent::__construct($validator);
167+
}
168+
169+
/**
170+
* @return iterable<string>
171+
*/
172+
protected function rules(): iterable
173+
{
174+
return ['foo', 'bar'];
175+
}
176+
177+
protected function stopOnFirstFailure(Command $command): bool
178+
{
179+
return $this->command === $command;
180+
}
181+
};
182+
183+
$this->validator
184+
->expects($this->once())
185+
->method('using')
186+
->willReturnSelf();
187+
188+
$this->validator
189+
->expects($this->once())
190+
->method('stopOnFirstFailure')
191+
->with(true)
192+
->willReturnSelf();
193+
194+
$this->validator
195+
->expects($this->once())
196+
->method('validate')
197+
->with($command)
198+
->willReturn($errors = new ListOfErrors(new Error(null, 'Something went wrong.')));
199+
200+
$next = function () {
201+
throw new \LogicException('Not expecting next closure to be called.');
202+
};
203+
204+
$result = ($this->middleware)($command, $next);
205+
206+
$this->assertTrue($result->didFail());
207+
$this->assertSame($errors, $result->errors());
208+
}
106209
}

0 commit comments

Comments
 (0)