Skip to content

Commit 8a7c631

Browse files
committed
feat(interceptors): Add InterceptorOptions with order and ConflictPolicy
1 parent 1038bc8 commit 8a7c631

File tree

8 files changed

+194
-22
lines changed

8 files changed

+194
-22
lines changed
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Testo\Module\Interceptor;
6+
7+
use Testo\Module\Interceptor\Policy\ConflictPolicy;
8+
9+
/**
10+
* Interceptor options.
11+
*/
12+
#[\Attribute(\Attribute::TARGET_CLASS)]
13+
final class InterceptorOptions
14+
{
15+
const DEFAULT_ORDER = 0;
16+
17+
public function __construct(
18+
/**
19+
* The priority of the interceptor.
20+
*
21+
* Lower priority interceptors are applied first in the interceptor chain.
22+
* Higher priority interceptors are closer to the test function in the interceptor chain.
23+
*/
24+
public readonly int $order = self::DEFAULT_ORDER,
25+
public readonly ConflictPolicy $onConflict = ConflictPolicy::Merge,
26+
) {}
27+
}

src/Module/Interceptor/InterceptorProvider.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
use Testo\Interceptor\Reflection\Reflection;
1616
use Testo\Interceptor\TestCaseCallInterceptor\InstantiateTestCase;
1717
use Testo\Module\Interceptor\Internal\InterceptorMarker;
18+
use Testo\Module\Interceptor\Internal\Sorter;
1819
use Testo\Sample\Internal\TestInlineFinder;
1920
use Yiisoft\Injector\Injector;
2021

@@ -89,7 +90,7 @@ public function fromClasses(string $class, string|InterceptorMarker ...$intercep
8990

9091
$interceptor instanceof $class and $result[] = $interceptor;
9192
}
92-
return $result;
93+
return Sorter::sortAndFilter($result);
9394
}
9495

9596
/**
@@ -115,7 +116,7 @@ public function fromAttributes(string $class, Interceptable ...$attributes): arr
115116
\is_a($iClass, $class, true) and $result[] = $this->createInstance($iClass, [$attribute]);
116117
}
117118

118-
return $result;
119+
return Sorter::sortAndFilter($result);
119120
}
120121

121122
/**
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Testo\Module\Interceptor\Internal;
6+
7+
use Testo\Interceptor\Reflection\Reflection;
8+
use Testo\Module\Interceptor\InterceptorOptions;
9+
use Testo\Module\Interceptor\Internal\InterceptorMarker as TInterceptor;
10+
use Testo\Module\Interceptor\Policy\ConflictPolicy;
11+
12+
/**
13+
* Sorts and filters interceptors.
14+
* @internal
15+
*/
16+
final class Sorter
17+
{
18+
/** @var array<class-string<TInterceptor>, ConflictPolicy> */
19+
private static array $conflictPolicyCache = [];
20+
21+
/** @var array<class-string<TInterceptor>, int> */
22+
private static array $orderCache = [];
23+
24+
/**
25+
* Sort and filter interceptors.
26+
*
27+
* @param TInterceptor[] $interceptors
28+
*
29+
* @return TInterceptor[]
30+
*/
31+
public static function sortAndFilter(array $interceptors): array
32+
{
33+
# Local caches
34+
$conflicts = [];
35+
$orders = [];
36+
37+
/** @var array<int, array<class-string<TInterceptor>|int, TInterceptor>> $groups */
38+
$groups = [];
39+
foreach ($interceptors as $interceptor) {
40+
$class = $interceptor::class;
41+
$conflict = $conflicts[$class] ??= self::getConflictPolicy($interceptor);
42+
$order = $orders[$class] ??= self::getOrder($interceptor);
43+
44+
switch ($conflict) {
45+
case ConflictPolicy::First:
46+
$groups[$order][$class] ??= $interceptor;
47+
break;
48+
case ConflictPolicy::Last:
49+
unset($groups[$order][$class]);
50+
$groups[$order][$class] = $interceptor;
51+
break;
52+
case ConflictPolicy::Error:
53+
\array_key_exists($class, $groups[$order])
54+
? throw new \RuntimeException("Conflict detected for interceptor '$class' with policy 'Error'.")
55+
: $groups[$order][$class] = $interceptor;
56+
break;
57+
default:
58+
$groups[$order][] = $interceptor;
59+
break;
60+
}
61+
}
62+
63+
\ksort($groups);
64+
return \array_values(\array_merge(...$groups));
65+
}
66+
67+
/**
68+
* Warm up the cache for the given interceptor class.
69+
*
70+
* @param class-string<TInterceptor> $class
71+
*/
72+
private static function warmUpCache(string $class): void
73+
{
74+
/** @var list<\ReflectionAttribute<InterceptorOptions>> $attributes */
75+
$attributes = Reflection::fetchClassAttributes(
76+
$class,
77+
attributeClass: InterceptorOptions::class,
78+
);
79+
80+
if ($attributes === []) {
81+
self::$conflictPolicyCache[$class] = ConflictPolicy::default();
82+
self::$orderCache[$class] = InterceptorOptions::DEFAULT_ORDER;
83+
return;
84+
}
85+
86+
$attribute = $attributes[0]->newInstance();
87+
self::$conflictPolicyCache[$class] = $attribute->onConflict;
88+
self::$orderCache[$class] = $attribute->order;
89+
}
90+
91+
/**
92+
* Get the conflict policy of the given interceptor by its attribute.
93+
*/
94+
private static function getConflictPolicy(TInterceptor $interceptor): ConflictPolicy
95+
{
96+
$class = $interceptor::class;
97+
\array_key_exists($class, self::$conflictPolicyCache) or self::warmUpCache($class);
98+
return self::$conflictPolicyCache[$class];
99+
}
100+
101+
/**
102+
* Get the order of the given interceptor by its attribute.
103+
*/
104+
private static function getOrder(TInterceptor $interceptor): int
105+
{
106+
$class = $interceptor::class;
107+
\array_key_exists($class, self::$orderCache) or self::warmUpCache($class);
108+
return self::$orderCache[$class];
109+
}
110+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Testo\Module\Interceptor\Policy;
6+
7+
/**
8+
* Merge policy for interceptors.
9+
*
10+
* Determines how to handle multiple interceptors of the same type.
11+
*/
12+
enum ConflictPolicy
13+
{
14+
/**
15+
* Use the first interceptor.
16+
* Default behavior.
17+
*/
18+
case First;
19+
20+
/**
21+
* Do nothing: all the interceptors will be applied.
22+
*/
23+
case Merge;
24+
25+
/**
26+
* Use the last interceptor.
27+
*/
28+
case Last;
29+
30+
/**
31+
* Break the pipeline with an error.
32+
*/
33+
case Error;
34+
35+
public static function default(): self
36+
{
37+
return self::First;
38+
}
39+
}

src/Sample/Internal/InlineTestInvoker.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@
44

55
namespace Testo\Sample\Internal;
66

7-
use Testo\Assert;use Testo\Sample\TestInline;use Testo\Test\Dto\TestInfo;class InlineTestInvoker
7+
use Testo\Assert;
8+
use Testo\Sample\TestInline;
9+
use Testo\Test\Dto\TestInfo;
10+
11+
class InlineTestInvoker
812
{
913
public function __construct() {}
1014

src/Sample/Internal/TestInlineInterceptor.php

Lines changed: 8 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,11 @@
55
namespace Testo\Sample\Internal;
66

77
use Psr\EventDispatcher\EventDispatcherInterface;
8-
use Testo\Attribute\Test;
9-
use Testo\Interceptor\CaseLocatorInterceptor;
10-
use Testo\Interceptor\FileLocatorInterceptor;
11-
use Testo\Interceptor\Reflection\Reflection;
128
use Testo\Interceptor\TestRunInterceptor;
13-
use Testo\Module\Tokenizer\Reflection\FileDefinitions;
14-
use Testo\Module\Tokenizer\Reflection\TokenizedFile;
9+
use Testo\Module\Interceptor\InterceptorOptions;
10+
use Testo\Module\Interceptor\Policy\ConflictPolicy;
1511
use Testo\Sample\MultipleResult;
1612
use Testo\Sample\TestInline;
17-
use Testo\Test\Dto\CaseDefinitions;
1813
use Testo\Test\Dto\Status;
1914
use Testo\Test\Dto\TestInfo;
2015
use Testo\Test\Dto\TestResult;
@@ -26,16 +21,12 @@
2621
/**
2722
* Interceptor that runs the target method as a pure function with provided arguments and expected result.
2823
*/
24+
#[InterceptorOptions(onConflict: ConflictPolicy::First)]
2925
final class TestInlineInterceptor implements TestRunInterceptor
3026
{
31-
/** @var callable(TestInfo): mixed Invoker for the test method. */
32-
private readonly \CLosure $invoker;
33-
3427
public function __construct(
3528
private readonly EventDispatcherInterface $eventDispatcher,
36-
) {
37-
$this->invoker = (new InlineTestInvoker())(...);
38-
}
29+
) {}
3930

4031
#[\Override]
4132
public function runTest(TestInfo $info, callable $next): TestResult
@@ -54,16 +45,16 @@ public function runTest(TestInfo $info, callable $next): TestResult
5445
# Dispatch batch starting event
5546
$this->eventDispatcher->dispatch(new TestBatchStarting($info));
5647

57-
// Run the test for each data set
48+
# Run the test for each data set
5849
$results = [];
5950
$status = Status::Passed;
6051
foreach ($attributes as $index => $inline) {
61-
$newInfo = $info->with(arguments: $inline->arguments);
52+
$newInfo = $info->with(arguments: $inline->arguments)->withAttribute(TestInline::class, $inline);
6253
$label = "$index";
6354

6455
# Dispatch dataset starting event
6556
$this->eventDispatcher->dispatch(
66-
new TestDataSetStarting($newInfo, $label, $index)
57+
new TestDataSetStarting($newInfo, $label, $index),
6758
);
6859

6960
try {
@@ -74,7 +65,7 @@ public function runTest(TestInfo $info, callable $next): TestResult
7465

7566
# Dispatch dataset finished event
7667
$this->eventDispatcher->dispatch(
77-
new TestDataSetFinished($newInfo, $result, $label, $index)
68+
new TestDataSetFinished($newInfo, $result, $label, $index),
7869
);
7970

8071
unset($inline, $newInfo);

testo.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,5 +16,6 @@
1616
),
1717
],
1818
require 'tests/Assert/suites.php',
19+
require 'tests/Sample/suites.php',
1920
),
2021
);

tests/Sample/Self/TestInline.php

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,6 @@
44

55
namespace Tests\Sample\Self;
66

7-
use Testo\Assert;
8-
use Testo\Attribute\ExpectException;
97
use Testo\Attribute\Test;
108
use Testo\Expect;
119

@@ -14,6 +12,7 @@
1412
*/
1513
final class TestInline
1614
{
15+
#[\Testo\Sample\TestInline(arguments: [1, 1], result: 2)]
1716
#[\Testo\Sample\TestInline(arguments: [40, 2], result: 42)]
1817
public function sum(int $a, int $b): int
1918
{

0 commit comments

Comments
 (0)