Skip to content

Commit c0dec74

Browse files
committed
feat(Common): Add Reflection::findMethodsWithAttribute()
1 parent 83fdc27 commit c0dec74

File tree

11 files changed

+243
-9
lines changed

11 files changed

+243
-9
lines changed

src/Common/Reflection.php

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,15 @@ final class Reflection
1313
* Fetch all attributes for a given function or method.
1414
*
1515
* @param \ReflectionFunctionAbstract $function The function or method to fetch attributes from.
16-
* @param bool $includeParents Whether to include attributes from parent methods (only applicable for methods).
16+
* @param bool $includePrototypes Whether to include attributes from method prototypes (only applicable for methods).
1717
* @param class-string|null $attributeClass If provided, only attributes of this class will be returned.
1818
* @param int $flags Flags to pass to {@see ReflectionFunctionAbstract::getAttributes()}.
1919
*
2020
* @return \ReflectionAttribute[]
2121
*/
2222
public static function fetchFunctionAttributes(
2323
\ReflectionFunctionAbstract $function,
24-
bool $includeParents = true,
24+
bool $includePrototypes = true,
2525
?string $attributeClass = null,
2626
int $flags = 0,
2727
): array {
@@ -30,11 +30,13 @@ public static function fetchFunctionAttributes(
3030
do {
3131
$attributes = \array_merge($attributes, $function->getAttributes($attributeClass, $flags));
3232

33-
if ($includeParents && $function instanceof \ReflectionMethod) {
34-
$parentClass = $function->getDeclaringClass()->getParentClass();
35-
if ($parentClass !== false && $parentClass->hasMethod($function->getName())) {
36-
$function = $parentClass->getMethod($function->getName());
33+
if ($includePrototypes && $function instanceof \ReflectionMethod) {
34+
# todo use ->hasPrototype() since php 8.2
35+
try {
36+
$function = $function->getPrototype();
3737
continue;
38+
} catch (\ReflectionException) {
39+
break;
3840
}
3941
}
4042

@@ -116,4 +118,42 @@ public static function fetchTraits(
116118

117119
return \array_unique($traits);
118120
}
121+
122+
/**
123+
* Find all methods in a class that have a specific attribute.
124+
*
125+
* @param \ReflectionClass|class-string $class The class to inspect.
126+
* @param class-string $attributeClass The attribute class to search for.
127+
* @param bool $includePrototypes Whether to search for the attribute in method prototypes if not found on the method itself.
128+
* @param int $flags Flags to pass to {@see ReflectionMethod::getAttributes()}.
129+
*
130+
* @return \ReflectionMethod[] An array of methods that have the specified attribute.
131+
*/
132+
public static function findMethodsWithAttribute(
133+
\ReflectionClass|string $class,
134+
string $attributeClass,
135+
bool $includePrototypes = true,
136+
int $flags = 0,
137+
): array {
138+
\is_string($class) and $class = new \ReflectionClass($class);
139+
140+
$methods = [];
141+
foreach ($class->getMethods() as $method) {
142+
do {
143+
if ($method->getAttributes($attributeClass, $flags) !== []) {
144+
$methods[] = $method;
145+
break;
146+
}
147+
148+
# todo use ->hasPrototype() since php 8.2
149+
try {
150+
$method = $method->getPrototype();
151+
} catch (\ReflectionException) {
152+
break;
153+
}
154+
} while ($includePrototypes);
155+
}
156+
157+
return $methods;
158+
}
119159
}

testo.php

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22

33
declare(strict_types=1);
44

5-
use Testo\Framework\Application\Config\ApplicationConfig;
6-
use Testo\Framework\Application\Config\FinderConfig;
7-
use Testo\Framework\Application\Config\SuiteConfig;
5+
use Testo\Application\Config\ApplicationConfig;
6+
use Testo\Application\Config\FinderConfig;
7+
use Testo\Application\Config\SuiteConfig;
88

99
return new ApplicationConfig(
1010
suites: \array_merge(
@@ -17,6 +17,7 @@
1717
),
1818
],
1919
require 'tests/Assert/suites.php',
20+
require 'tests/Common/suites.php',
2021
require 'tests/Sample/suites.php',
2122
),
2223
);
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Common\Self;
6+
7+
use Testo\Assert;
8+
use Testo\Common\Reflection;
9+
use Tests\Common\Stub\AnotherMarkerAttribute;
10+
use Tests\Common\Stub\BaseClass;
11+
use Tests\Common\Stub\ChildClass;
12+
use Tests\Common\Stub\ClassWithoutMarkedMethods;
13+
use Tests\Common\Stub\MarkerAttribute;
14+
use Tests\Common\Stub\MiddleClass;
15+
16+
function testFindsDirectlyMarkedMethods(): void
17+
{
18+
$methods = Reflection::findMethodsWithAttribute(BaseClass::class, MarkerAttribute::class);
19+
20+
$names = \array_map(static fn(\ReflectionMethod $m) => $m->getName(), $methods);
21+
\sort($names);
22+
23+
Assert::same(['baseMethod', 'overriddenMethod'], $names);
24+
}
25+
26+
function testFindsInheritedMarkedMethods(): void
27+
{
28+
$methods = Reflection::findMethodsWithAttribute(MiddleClass::class, MarkerAttribute::class);
29+
30+
$names = \array_map(static fn(\ReflectionMethod $m) => $m->getName(), $methods);
31+
\sort($names);
32+
33+
Assert::same(['baseMethod', 'overriddenMethod'], $names);
34+
}
35+
36+
function testFindsMethodsMarkedInPrototype(): void
37+
{
38+
$methods = Reflection::findMethodsWithAttribute(ChildClass::class, MarkerAttribute::class);
39+
40+
$names = \array_map(static fn(\ReflectionMethod $m) => $m->getName(), $methods);
41+
\sort($names);
42+
43+
Assert::same(['baseMethod', 'childMethod', 'interfaceMethod', 'overriddenMethod'], $names);
44+
}
45+
46+
function testFindsMethodsFromInterface(): void
47+
{
48+
$methods = Reflection::findMethodsWithAttribute(ChildClass::class, MarkerAttribute::class);
49+
50+
$names = \array_map(static fn(\ReflectionMethod $m) => $m->getName(), $methods);
51+
52+
Assert::true(\in_array('interfaceMethod', $names, true));
53+
}
54+
55+
function testWithoutPrototypesSkipsPrototypeSearch(): void
56+
{
57+
$methods = Reflection::findMethodsWithAttribute(
58+
ChildClass::class,
59+
MarkerAttribute::class,
60+
includePrototypes: false,
61+
);
62+
63+
$names = \array_map(static fn(\ReflectionMethod $m) => $m->getName(), $methods);
64+
\sort($names);
65+
66+
// baseMethod has attribute directly, childMethod has attribute directly
67+
// interfaceMethod and overriddenMethod only have attributes in prototypes
68+
Assert::same(['baseMethod', 'childMethod'], $names);
69+
}
70+
71+
function testReturnsEmptyArrayWhenNoMatches(): void
72+
{
73+
$methods = Reflection::findMethodsWithAttribute(ClassWithoutMarkedMethods::class, MarkerAttribute::class);
74+
75+
Assert::same([], $methods);
76+
}
77+
78+
function testFiltersByAttributeClass(): void
79+
{
80+
$methods = Reflection::findMethodsWithAttribute(ChildClass::class, AnotherMarkerAttribute::class);
81+
82+
$names = \array_map(static fn(\ReflectionMethod $m) => $m->getName(), $methods);
83+
84+
Assert::same(['middleMethod'], $names);
85+
}
86+
87+
function testAcceptsReflectionClassInstance(): void
88+
{
89+
$ref = new \ReflectionClass(BaseClass::class);
90+
$methods = Reflection::findMethodsWithAttribute($ref, MarkerAttribute::class);
91+
92+
$names = \array_map(static fn(\ReflectionMethod $m) => $m->getName(), $methods);
93+
\sort($names);
94+
95+
Assert::same(['baseMethod', 'overriddenMethod'], $names);
96+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Common\Stub;
6+
7+
#[\Attribute(\Attribute::TARGET_METHOD)]
8+
final class AnotherMarkerAttribute {}

tests/Common/Stub/BaseClass.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Common\Stub;
6+
7+
abstract class BaseClass
8+
{
9+
#[MarkerAttribute]
10+
public function baseMethod(): void {}
11+
12+
public function unmarkedMethod(): void {}
13+
14+
#[MarkerAttribute]
15+
public function overriddenMethod(): void {}
16+
}

tests/Common/Stub/ChildClass.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Common\Stub;
6+
7+
class ChildClass extends MiddleClass implements MarkedInterface
8+
{
9+
#[MarkerAttribute]
10+
public function childMethod(): void {}
11+
12+
public function interfaceMethod(): void {}
13+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Common\Stub;
6+
7+
class ClassWithoutMarkedMethods
8+
{
9+
public function method(): void {}
10+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Common\Stub;
6+
7+
interface MarkedInterface
8+
{
9+
#[MarkerAttribute]
10+
public function interfaceMethod(): void;
11+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Common\Stub;
6+
7+
#[\Attribute(\Attribute::TARGET_METHOD)]
8+
final class MarkerAttribute {}

tests/Common/Stub/MiddleClass.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Common\Stub;
6+
7+
class MiddleClass extends BaseClass
8+
{
9+
#[AnotherMarkerAttribute]
10+
public function middleMethod(): void {}
11+
12+
public function overriddenMethod(): void {}
13+
}

0 commit comments

Comments
 (0)