From d87ec5835da7a474ef80720a25a86e24f33f4ee4 Mon Sep 17 00:00:00 2001 From: Sava <141237670+gurmanolog@users.noreply.github.com> Date: Mon, 26 Jan 2026 14:24:36 +0100 Subject: [PATCH] Support all DNF types from PHP RFC (#1751) * Support all DNF types from PHP RFC * Update failing test --------- Co-authored-by: Sava Markovic --- src/Console/ModelsCommand.php | 48 +++- src/Macro.php | 43 +++- .../DnfTypes/Models/DnfTypeModel.php | 218 ++++++++++++++++ tests/Console/ModelsCommand/DnfTypes/Test.php | 24 ++ .../DnfTypes/__snapshots__/Test__test__1.php | 234 ++++++++++++++++++ tests/MacroTest.php | 57 +++++ 6 files changed, 609 insertions(+), 15 deletions(-) create mode 100644 tests/Console/ModelsCommand/DnfTypes/Models/DnfTypeModel.php create mode 100644 tests/Console/ModelsCommand/DnfTypes/Test.php create mode 100644 tests/Console/ModelsCommand/DnfTypes/__snapshots__/Test__test__1.php diff --git a/src/Console/ModelsCommand.php b/src/Console/ModelsCommand.php index 9c85c09..c140e7b 100644 --- a/src/Console/ModelsCommand.php +++ b/src/Console/ModelsCommand.php @@ -1661,7 +1661,8 @@ class ModelsCommand extends Command $type = implode('|', $types); if ($paramType->allowsNull()) { - if (count($types) == 1) { + // Use ?Type syntax only for single named types, not for intersection types + if (count($types) == 1 && !str_starts_with($type, '(')) { $type = '?' . $type; } else { $type .= '|null'; @@ -1734,24 +1735,53 @@ class ModelsCommand extends Command return $type; } - protected function extractReflectionTypes(ReflectionType $reflection_type) + protected function extractReflectionTypes(ReflectionType $reflection_type): array { if ($reflection_type instanceof ReflectionNamedType) { - $types[] = $this->getReflectionNamedType($reflection_type); - } else { - $types = []; - foreach ($reflection_type->getTypes() as $named_type) { - if ($named_type->getName() === 'null') { + return [$this->getReflectionNamedType($reflection_type)]; + } + + if ($reflection_type instanceof \ReflectionIntersectionType) { + return [$this->formatIntersectionType($reflection_type)]; + } + + if ($reflection_type instanceof \ReflectionUnionType) { + return $this->extractUnionTypes($reflection_type); + } + + // Unknown type - return empty array as fallback + return []; + } + + protected function extractUnionTypes(\ReflectionUnionType $union_type): array + { + $types = []; + + foreach ($union_type->getTypes() as $inner_type) { + if ($inner_type instanceof ReflectionNamedType) { + if ($inner_type->getName() === 'null') { continue; } - - $types[] = $this->getReflectionNamedType($named_type); + $types[] = $this->getReflectionNamedType($inner_type); + } elseif ($inner_type instanceof \ReflectionIntersectionType) { + $types[] = $this->formatIntersectionType($inner_type); } + // ReflectionUnionType cannot be nested per PHP's DNF rules } return $types; } + protected function formatIntersectionType(\ReflectionIntersectionType $intersection_type): string + { + $parts = []; + foreach ($intersection_type->getTypes() as $type) { + $parts[] = $this->getReflectionNamedType($type); + } + + return '(' . implode('&', $parts) . ')'; + } + protected function getReflectionNamedType(ReflectionNamedType $paramType): string { $parameterName = $paramType->getName(); diff --git a/src/Macro.php b/src/Macro.php index 6e3fd45..2213fa6 100644 --- a/src/Macro.php +++ b/src/Macro.php @@ -93,17 +93,48 @@ class Macro extends Method protected function concatReflectionTypes(?\ReflectionType $type): string { - /** @psalm-suppress UndefinedClass */ - $returnTypes = $type instanceof \ReflectionUnionType - ? $type->getTypes() - : [$type]; + if ($type instanceof \ReflectionNamedType) { + return $type->getName(); + } - return Collection::make($returnTypes) + if ($type instanceof \ReflectionIntersectionType) { + return $this->formatIntersectionType($type); + } + + if ($type instanceof \ReflectionUnionType) { + return $this->formatUnionType($type); + } + + // Unknown or null type + return ''; + } + + protected function formatUnionType(\ReflectionUnionType $type): string + { + return Collection::make($type->getTypes()) + ->map(function (\ReflectionType $inner) { + if ($inner instanceof \ReflectionNamedType) { + return $inner->getName(); + } + if ($inner instanceof \ReflectionIntersectionType) { + return $this->formatIntersectionType($inner); + } + // ReflectionUnionType cannot be nested per PHP's DNF rules + return null; + }) ->filter() - ->map->getName() ->implode('|'); } + protected function formatIntersectionType(\ReflectionIntersectionType $type): string + { + $parts = Collection::make($type->getTypes()) + ->map(fn (\ReflectionNamedType $t) => $t->getName()) + ->toArray(); + + return '(' . implode('&', $parts) . ')'; + } + protected function addLocationToPhpDoc() { if ($this->method->name === '__invoke') { diff --git a/tests/Console/ModelsCommand/DnfTypes/Models/DnfTypeModel.php b/tests/Console/ModelsCommand/DnfTypes/Models/DnfTypeModel.php new file mode 100644 index 0000000..e8b39ae --- /dev/null +++ b/tests/Console/ModelsCommand/DnfTypes/Models/DnfTypeModel.php @@ -0,0 +1,218 @@ +app->make(ModelsCommand::class); + + $tester = $this->runCommand($command, [ + '--write' => true, + ]); + + $this->assertSame(0, $tester->getStatusCode()); + $this->assertStringContainsString('Written new phpDocBlock to', $tester->getDisplay()); + $this->assertMatchesMockedSnapshot(); + } +} diff --git a/tests/Console/ModelsCommand/DnfTypes/__snapshots__/Test__test__1.php b/tests/Console/ModelsCommand/DnfTypes/__snapshots__/Test__test__1.php new file mode 100644 index 0000000..fd8e1fa --- /dev/null +++ b/tests/Console/ModelsCommand/DnfTypes/__snapshots__/Test__test__1.php @@ -0,0 +1,234 @@ +|DnfTypeModel newModelQuery() + * @method static \Illuminate\Database\Eloquent\Builder|DnfTypeModel newQuery() + * @method static \Illuminate\Database\Eloquent\Builder|DnfTypeModel query() + * @method static \Illuminate\Database\Eloquent\Builder|DnfTypeModel withBasicDnfParam((\Countable&\Iterator)|null $param) + * @method static \Illuminate\Database\Eloquent\Builder|DnfTypeModel withComplexDnfParam(\stdClass|(\Countable&\Iterator)|null $param) + * @method static \Illuminate\Database\Eloquent\Builder|DnfTypeModel withDnfParamAndReturn((\Countable&\Iterator)|null $param) + * @method static \Illuminate\Database\Eloquent\Builder|DnfTypeModel withIntersectionOrPrimitive((\Countable&\Iterator)|int $param) + * @method static \Illuminate\Database\Eloquent\Builder|DnfTypeModel withMultipleIntersectionParam((\Countable&\Iterator)|(\ArrayAccess&\Stringable) $param) + * @mixin \Eloquent + */ +class DnfTypeModel extends Model +{ + // ========================================================================= + // RFC Example 1: (A&B)|null - Intersection with null + // ========================================================================= + + /** + * Basic DNF: intersection OR null. + */ + public function getBasicDnfAttribute(): (Countable&Iterator)|null + { + return null; + } + + // ========================================================================= + // RFC Example 2: (A&B)|D - Intersection OR single type (no null) + // ========================================================================= + + /** + * Intersection OR single class type. + */ + public function getIntersectionOrClassAttribute(): (Countable&Iterator)|\stdClass + { + return new \stdClass(); + } + + // ========================================================================= + // RFC Example 3: C|(X&D)|null - Single type OR intersection OR null + // ========================================================================= + + /** + * Single type OR intersection OR null. + */ + public function getSingleOrIntersectionOrNullAttribute(): \stdClass|(Countable&Iterator)|null + { + return null; + } + + // ========================================================================= + // RFC Example 4: (A&B&D)|int|null - Triple intersection OR primitive OR null + // ========================================================================= + + /** + * Triple intersection OR primitive OR null. + */ + public function getTripleIntersectionAttribute(): (Countable&Iterator&Traversable)|int|null + { + return null; + } + + // ========================================================================= + // RFC Example 5: (A&B)|(C&D)|null - Multiple intersection segments + // ========================================================================= + + /** + * Multiple intersection segments OR null. + */ + public function getMultipleIntersectionsAttribute(): (Countable&Iterator)|(ArrayAccess&Stringable)|null + { + return null; + } + + // ========================================================================= + // RFC Example 6: (A&B)|(C&D)|(E&F) - Multiple intersections without null + // ========================================================================= + + /** + * Multiple intersection segments without null. + */ + public function getMultipleIntersectionsNoNullAttribute(): (Countable&Iterator)|(ArrayAccess&Stringable)|(Traversable&Countable) + { + return new class () implements Countable, Iterator { + public function count(): int + { + return 0; + } + + public function current(): mixed + { + return null; + } + + public function key(): mixed + { + return null; + } + + public function next(): void + { + } + + public function rewind(): void + { + } + + public function valid(): bool + { + return false; + } + }; + } + + // ========================================================================= + // Pure intersection type (A&B) - No union, just intersection + // ========================================================================= + + /** + * Pure intersection type without union. + */ + public function getPureIntersectionAttribute(): Countable&Iterator + { + return new class () implements Countable, Iterator { + public function count(): int + { + return 0; + } + + public function current(): mixed + { + return null; + } + + public function key(): mixed + { + return null; + } + + public function next(): void + { + } + + public function rewind(): void + { + } + + public function valid(): bool + { + return false; + } + }; + } + + // ========================================================================= + // Parameter context: DNF types in method parameters + // ========================================================================= + + /** + * Scope with DNF parameter: (A&B)|null. + */ + public function scopeWithBasicDnfParam( + \Illuminate\Database\Query\Builder $query, + (Countable&Iterator)|null $param + ): \Illuminate\Database\Query\Builder { + return $query; + } + + /** + * Scope with complex DNF parameter: C|(A&B)|null. + */ + public function scopeWithComplexDnfParam( + \Illuminate\Database\Query\Builder $query, + \stdClass|(Countable&Iterator)|null $param + ): \Illuminate\Database\Query\Builder { + return $query; + } + + /** + * Scope with multiple intersection parameters. + */ + public function scopeWithMultipleIntersectionParam( + \Illuminate\Database\Query\Builder $query, + (Countable&Iterator)|(ArrayAccess&Stringable) $param + ): \Illuminate\Database\Query\Builder { + return $query; + } + + /** + * Scope with intersection OR primitive parameter. + */ + public function scopeWithIntersectionOrPrimitive( + \Illuminate\Database\Query\Builder $query, + (Countable&Iterator)|int $param + ): \Illuminate\Database\Query\Builder { + return $query; + } + + // ========================================================================= + // Mixed: DNF in both parameter and return type + // ========================================================================= + + /** + * DNF in both parameter and return type. + */ + public function scopeWithDnfParamAndReturn( + \Illuminate\Database\Query\Builder $query, + (Countable&Iterator)|null $param + ): (Countable&Iterator)|\Illuminate\Database\Query\Builder { + return $query; + } +} diff --git a/tests/MacroTest.php b/tests/MacroTest.php index 6b18c54..9ca1f83 100644 --- a/tests/MacroTest.php +++ b/tests/MacroTest.php @@ -207,6 +207,63 @@ class MacroTest extends TestCase $this->assertEquals('@return \Stringable|string|null', $this->tagsToString($phpdoc, 'return')); } + public function testInitPhpDocParamsWithDnfTypes(): void + { + $phpdoc = (new MacroMock())->getPhpDoc(eval(<<<'PHP' + return new ReflectionFunction( + /** + * Test docblock with DNF types. + */ + function ((\Countable&\Iterator)|null $a): (\Countable&\Iterator)|\stdClass|null { + return $a; + } + ); + PHP)); + + $this->assertNotNull($phpdoc); + $this->assertStringContainsString('Test docblock with DNF types', $phpdoc->getText()); + $this->assertEquals('@param (Countable&Iterator)|null $a', $this->tagsToString($phpdoc, 'param')); + $this->assertEquals('@return (Countable&Iterator)|\stdClass|null', $this->tagsToString($phpdoc, 'return')); + } + + public function testInitPhpDocParamsWithPureIntersectionType(): void + { + $phpdoc = (new MacroMock())->getPhpDoc(eval(<<<'PHP' + return new ReflectionFunction( + /** + * Test docblock with pure intersection type. + */ + function (\Countable&\Iterator $a): \Countable&\Iterator { + return $a; + } + ); + PHP)); + + $this->assertNotNull($phpdoc); + $this->assertStringContainsString('Test docblock with pure intersection type', $phpdoc->getText()); + $this->assertEquals('@param (Countable&Iterator) $a', $this->tagsToString($phpdoc, 'param')); + $this->assertEquals('@return (Countable&Iterator)', $this->tagsToString($phpdoc, 'return')); + } + + public function testInitPhpDocParamsWithMultipleIntersections(): void + { + $phpdoc = (new MacroMock())->getPhpDoc(eval(<<<'PHP' + return new ReflectionFunction( + /** + * Test docblock with multiple intersection segments. + */ + function ((\Countable&\Iterator)|(\ArrayAccess&\Stringable) $a): (\Countable&\Iterator)|(\ArrayAccess&\Stringable)|null { + return $a; + } + ); + PHP)); + + $this->assertNotNull($phpdoc); + $this->assertStringContainsString('Test docblock with multiple intersection segments', $phpdoc->getText()); + $this->assertEquals('@param (Countable&Iterator)|(ArrayAccess&Stringable) $a', $this->tagsToString($phpdoc, 'param')); + $this->assertEquals('@return (Countable&Iterator)|(ArrayAccess&Stringable)|null', $this->tagsToString($phpdoc, 'return')); + } + protected function tagsToString(DocBlock $docBlock, string $name) { $tags = $docBlock->getTagsByName($name);