Skip to content

Commit ff697c3

Browse files
committed
feat: implement metadata support
1 parent a35baa0 commit ff697c3

8 files changed

Lines changed: 240 additions & 22 deletions

File tree

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Constructo\Core\Reflect\Introspection;
6+
7+
use Iterator;
8+
use ReflectionClass;
9+
use ReflectionException;
10+
11+
class Introspector
12+
{
13+
public function analyze(string $source): Result
14+
{
15+
try {
16+
$reflection = new ReflectionClass($source);
17+
18+
if (! $reflection->implementsInterface(Iterator::class)) {
19+
return new Result($source);
20+
}
21+
22+
$currentMethod = $reflection->getMethod('current');
23+
return new Result($source, $currentMethod->getReturnType());
24+
} catch (ReflectionException) {
25+
// If reflection fails, return null
26+
}
27+
return new Result($source);
28+
}
29+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Constructo\Core\Reflect\Introspection;
6+
7+
use ReflectionNamedType;
8+
use ReflectionType;
9+
10+
readonly class Result
11+
{
12+
public function __construct(
13+
public string $source,
14+
public ?ReflectionType $type = null,
15+
) {
16+
}
17+
18+
public function introspectable(): ?string
19+
{
20+
return (($this->type instanceof ReflectionNamedType) && ! $this->type->isBuiltin())
21+
? $this->type->getName()
22+
: null;
23+
}
24+
}

src/Core/Reflect/Reflector.php

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
namespace Constructo\Core\Reflect;
66

7+
use Constructo\Core\Reflect\Introspection\Introspector;
78
use Constructo\Core\Reflect\Resolver\ManagedResolver;
89
use Constructo\Core\Reflect\Resolver\RequirementResolver;
910
use Constructo\Core\Reflect\Resolver\TypeResolver;
@@ -30,10 +31,13 @@ class Reflector
3031
private array $sources = [];
3132

3233
public function __construct(
33-
protected readonly SchemaFactory $factory,
34-
protected readonly Types $types,
35-
protected readonly Cache $cache,
36-
protected readonly Notation $notation = Notation::SNAKE,
34+
private readonly SchemaFactory $factory,
35+
private readonly Types $types,
36+
private readonly Cache $cache,
37+
private readonly Introspector $introspector,
38+
private readonly Notation $notation = Notation::SNAKE,
39+
private readonly string $conector = '.',
40+
private readonly string $expansor = '*',
3741
) {
3842
}
3943

@@ -69,7 +73,7 @@ protected function introspect(array $parameters, Schema $schema, ?Field $parent
6973
...$path,
7074
format($parameter->getName(), $this->notation),
7175
];
72-
$name = implode('.', $nestedPath);
76+
$name = implode($this->conector, $nestedPath);
7377

7478
$field = $schema->add($name);
7579
$chain->resolve($parameter, $field, $nestedPath);
@@ -88,6 +92,13 @@ protected function introspect(array $parameters, Schema $schema, ?Field $parent
8892
*/
8993
private function introspectSource(string $source, Schema $schema, Field $parent, array $path): void
9094
{
95+
$result = $this->introspector->analyze($source);
96+
$introspection = $result->introspectable();
97+
if ($introspection !== null) {
98+
$source = $introspection;
99+
$path = [...$path, $this->expansor];
100+
}
101+
91102
$nestedParameters = $this->extractParameters($source);
92103
if (empty($nestedParameters)) {
93104
return;

src/Factory/ReflectorFactory.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
namespace Constructo\Factory;
66

77
use Constructo\Contract\Reflect\TypesFactory;
8+
use Constructo\Core\Reflect\Introspection\Introspector;
89
use Constructo\Core\Reflect\Reflector;
910
use Constructo\Support\Cache;
1011
use Constructo\Support\Reflective\Notation;
@@ -15,13 +16,14 @@ public function __construct(
1516
private TypesFactory $typesFactory,
1617
private SchemaFactory $schemaFactory,
1718
private Cache $cache,
19+
private Introspector $introspector,
1820
private Notation $notation = Notation::SNAKE,
1921
) {
2022
}
2123

2224
public function make(): Reflector
2325
{
2426
$types = $this->typesFactory->make();
25-
return new Reflector($this->schemaFactory, $types, $this->cache, $this->notation);
27+
return new Reflector($this->schemaFactory, $types, $this->cache, $this->introspector, $this->notation);
2628
}
2729
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Constructo\Test\Core\Reflect;
6+
7+
use Constructo\Core\Reflect\Introspection\Introspector;
8+
use Constructo\Test\Stub\Domain\Collection\Game\FeatureCollection;
9+
use Constructo\Test\Stub\Domain\Entity\Game\Feature;
10+
use Iterator;
11+
use PHPUnit\Framework\TestCase;
12+
use stdClass;
13+
14+
final class IntrospectorTest extends TestCase
15+
{
16+
private Introspector $introspector;
17+
18+
protected function setUp(): void
19+
{
20+
$this->introspector = new Introspector();
21+
}
22+
23+
public function testAnalyzeReturnsResultForNonIteratorClass(): void
24+
{
25+
$result = $this->introspector->analyze(stdClass::class);
26+
27+
$this->assertEquals(stdClass::class, $result->source);
28+
$this->assertNull($result->introspectable());
29+
}
30+
31+
public function testAnalyzeReturnsResultForNonExistentClass(): void
32+
{
33+
$result = $this->introspector->analyze('NonExistentClass');
34+
35+
$this->assertEquals('NonExistentClass', $result->source);
36+
$this->assertNull($result->introspectable());
37+
}
38+
39+
public function testAnalyzeReturnsCorrectTypeForIteratorWithTypedCurrentMethod(): void
40+
{
41+
$result = $this->introspector->analyze(FeatureCollection::class);
42+
43+
$this->assertEquals(FeatureCollection::class, $result->source);
44+
$this->assertEquals(Feature::class, $result->introspectable());
45+
}
46+
47+
public function testAnalyzeReturnsNullForIteratorWithMixedReturnType(): void
48+
{
49+
$iterator = new class implements Iterator {
50+
public function current(): mixed {
51+
return null;
52+
}
53+
54+
public function next(): void {}
55+
public function key(): mixed { return null; }
56+
public function valid(): bool { return false; }
57+
public function rewind(): void {}
58+
};
59+
60+
$result = $this->introspector->analyze($iterator::class);
61+
62+
$this->assertEquals($iterator::class, $result->source);
63+
$this->assertNull($result->introspectable());
64+
}
65+
66+
public function testAnalyzeHandlesBuiltInTypes(): void
67+
{
68+
$iterator = new class implements Iterator {
69+
public function current(): string {
70+
return '';
71+
}
72+
73+
public function next(): void {}
74+
public function key(): mixed { return null; }
75+
public function valid(): bool { return false; }
76+
public function rewind(): void {}
77+
};
78+
79+
$result = $this->introspector->analyze($iterator::class);
80+
81+
$this->assertEquals($iterator::class, $result->source);
82+
$this->assertNull($result->introspectable());
83+
}
84+
85+
public function testAnalyzeHandlesUnionTypes(): void
86+
{
87+
$iterator = new class implements Iterator {
88+
public function current(): string|int {
89+
return '';
90+
}
91+
92+
public function next(): void {}
93+
public function key(): mixed { return null; }
94+
public function valid(): bool { return false; }
95+
public function rewind(): void {}
96+
};
97+
98+
$result = $this->introspector->analyze($iterator::class);
99+
100+
$this->assertEquals($iterator::class, $result->source);
101+
$this->assertNull($result->introspectable());
102+
}
103+
}

tests/Core/Reflect/ReflectorTest.php

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
namespace Constructo\Test\Core\Reflect;
66

77
use Constructo\Contract\Reflect\TypesFactory;
8+
use Constructo\Core\Reflect\Introspection\Introspector;
89
use Constructo\Core\Reflect\Reflector;
910
use Constructo\Factory\DefaultSpecsFactory;
1011
use Constructo\Factory\SchemaFactory;
@@ -13,6 +14,7 @@
1314
use Constructo\Support\Reflective\Notation;
1415
use Constructo\Test\Stub\Domain\Entity\Command\GameCommand;
1516
use Constructo\Test\Stub\Domain\Entity\Command\PersonCommand;
17+
use Constructo\Test\Stub\Domain\Entity\EmptyClass;
1618
use Constructo\Test\Stub\Reflector\Sample;
1719
use PHPUnit\Framework\TestCase;
1820

@@ -26,6 +28,7 @@ protected function setUp(): void
2628
{
2729
$types = new Types();
2830
$cache = new Cache();
31+
$introspector = new Introspector();
2932
$notation = Notation::SNAKE;
3033

3134
$typesFactory = $this->createMock(TypesFactory::class);
@@ -73,7 +76,7 @@ protected function setUp(): void
7376
$schemaFactory = new SchemaFactory($specsFactory);
7477

7578

76-
$this->reflector = new Reflector($schemaFactory, $types, $cache, $notation);
79+
$this->reflector = new Reflector($schemaFactory, $types, $cache, $introspector, $notation);
7780
}
7881

7982
public function testReflectSampleClassCreatesSchemaWithAllFields(): void
@@ -228,6 +231,9 @@ public function testExtractCompleteJsonSchemaStructure(): void
228231
"optional_object_field.published_at" : [ "sometimes", "date" ],
229232
"optional_object_field.data" : [ "sometimes", "array" ],
230233
"optional_object_field.features" : [ "sometimes", "array" ],
234+
"optional_object_field.features.*.name" : [ "sometimes", "string" ],
235+
"optional_object_field.features.*.description" : [ "sometimes", "string" ],
236+
"optional_object_field.features.*.enabled" : [ "sometimes", "boolean" ],
231237
"default_enum_field" : [ "sometimes", "required", "integer", "in:1,2,3" ],
232238
"processed_nullable_field" : [ "sometimes", "nullable", "string" ]
233239
}';
@@ -248,7 +254,7 @@ public function testReflectorReturnsCorrectRulesForGameCommand(): void
248254
"features": [ "required", "array" ],
249255
"features.*.name": [ "required", "string" ],
250256
"features.*.description": [ "required", "string" ],
251-
"features.*.enabled": [ "required", "bool" ]
257+
"features.*.enabled": [ "required", "boolean" ]
252258
}';
253259
$expected = json_decode($json, true);
254260
$this->assertEquals($expected, $rules);
@@ -273,4 +279,26 @@ public function testReflectReturnsCorrectRulesForPersonCommand(): void
273279
$expected = json_decode($json, true);
274280
$this->assertEquals($expected, $rules);
275281
}
282+
283+
public function testReflectClassWithEmptySourceReturnsEarly(): void
284+
{
285+
// Create a stub class that has EmptyClass as a field source
286+
$stubClass = new class(new EmptyClass()) {
287+
public function __construct(
288+
public EmptyClass $emptyField,
289+
) {
290+
}
291+
};
292+
293+
$schema = $this->reflector->reflect($stubClass::class);
294+
$rules = $schema->rules();
295+
296+
// Should have the field but no nested rules since EmptyClass has no parameters
297+
$this->assertArrayHasKey('empty_field', $rules);
298+
$this->assertEquals(['required', 'array'], $rules['empty_field']);
299+
300+
// Should not have any nested rules for empty_field since EmptyClass has no constructor parameters
301+
$nestedKeys = array_filter(array_keys($rules), fn($key) => str_starts_with($key, 'empty_field.'));
302+
$this->assertEmpty($nestedKeys);
303+
}
276304
}

0 commit comments

Comments
 (0)