diff --git a/CHANGELOG.md b/CHANGELOG.md index 335239e..aa24e48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ - fix `average()` so non-numeric values no longer error on modern PHP versions - make `changeKeyCase()` Unicode case conversion deterministic across PHP 8.0–8.5 - strengthen native property type checks, array-shape contracts, and regression coverage across Json mapper and collection helpers +- add PHPStan + runtime coverage for `meta()` with array-shape-backed models and document the recommended usage in the README +- stabilize the full PHPUnit / PHPStan CI matrix across PHP 8.0–8.5 for both lowest and current dependency sets - remove stale PHP 8-only compatibility branches, clean up PHPStan ignores, and refresh the PHP 8.0+ docs/CI matrix ### 7.10.0 (2026-04-24) diff --git a/README.md b/README.md index a490fd3..55c48cd 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,7 @@ $arrayy->Lars->lastname; // 'Müller' ## PhpDoc array-shape / property checking -The library offers type checking for phpdoc array-shape annotations, legacy `@property` phpdoc-class-comments, and native declared properties. Prefer the array-shape form because it can reuse the `Arrayy` template for IDE autocompletion and static-analysis support. When you want PHPStan to check reads precisely, prefer array-like access with literal keys (for example `$user['lastName']`) on these array-shape-based models. Do not combine array-shape annotations and `@property` tags on the same model. +The library offers type checking for phpdoc array-shape annotations, legacy `@property` phpdoc-class-comments, and native declared properties. Prefer the array-shape form because it can reuse the `Arrayy` template for IDE autocompletion and static-analysis support. `meta()` is also understood by PHPStan, so `meta()`-derived keys such as `$userMeta->city` and `$cityMeta->name` keep precise literal-string information during static analysis. When you want PHPStan to check reads precisely, prefer array-like access with literal keys (for example `$user['lastName']`) or narrowed `meta()` keys on these array-shape-based models. Do not combine array-shape annotations and `@property` tags on the same model. ```php /** diff --git a/build/docs/base.md b/build/docs/base.md index b182a06..793c9ed 100644 --- a/build/docs/base.md +++ b/build/docs/base.md @@ -117,7 +117,7 @@ $arrayy->Lars->lastname; // 'Müller' ## PhpDoc array-shape / property checking -The library offers type checking for phpdoc array-shape annotations, legacy `@property` phpdoc-class-comments, and native declared properties. Prefer the array-shape form because it can reuse the `Arrayy` template for IDE autocompletion and static-analysis support. When you want PHPStan to check reads precisely, prefer array-like access with literal keys (for example `$user['lastName']`) on these array-shape-based models. Do not combine array-shape annotations and `@property` tags on the same model. +The library offers type checking for phpdoc array-shape annotations, legacy `@property` phpdoc-class-comments, and native declared properties. Prefer the array-shape form because it can reuse the `Arrayy` template for IDE autocompletion and static-analysis support. `meta()` is also understood by PHPStan, so `meta()`-derived keys such as `$userMeta->city` and `$cityMeta->name` keep precise literal-string information during static analysis. When you want PHPStan to check reads precisely, prefer array-like access with literal keys (for example `$user['lastName']`) or narrowed `meta()` keys on these array-shape-based models. Do not combine array-shape annotations and `@property` tags on the same model. ```php /** diff --git a/phpstan.neon b/phpstan.neon index d865762..ff963b3 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -4,3 +4,9 @@ parameters: paths: - %currentWorkingDirectory%/src/ - %currentWorkingDirectory%/tests/ + +services: + - + class: Arrayy\PHPStan\MetaDynamicStaticMethodReturnTypeExtension + tags: + - phpstan.broker.dynamicStaticMethodReturnTypeExtension diff --git a/src/Arrayy.php b/src/Arrayy.php index 105d428..55f51ad 100644 --- a/src/Arrayy.php +++ b/src/Arrayy.php @@ -117,6 +117,7 @@ class Arrayy extends \ArrayObject implements \IteratorAggregate, \ArrayAccess, \ * true, otherwise this option didn't not work anyway. *

* + * @phpstan-param TData|self|\Traversable|callable|object|scalar|null $data * @phpstan-param class-string<\Arrayy\ArrayyIterator> $iteratorClass */ public function __construct( @@ -1752,6 +1753,7 @@ public function countValues(): self * @return static *

(Immutable) Returns an new instance of the Arrayy object.

* + * @phpstan-param TData|self|\Traversable|callable|object|scalar|null $data * @phpstan-param class-string<\Arrayy\ArrayyIterator> $iteratorClass * @phpstan-return static * @psalm-mutation-free diff --git a/src/PHPStan/MetaDynamicStaticMethodReturnTypeExtension.php b/src/PHPStan/MetaDynamicStaticMethodReturnTypeExtension.php new file mode 100644 index 0000000..feb0c21 --- /dev/null +++ b/src/PHPStan/MetaDynamicStaticMethodReturnTypeExtension.php @@ -0,0 +1,63 @@ + + */ + private array $types = []; + + public function getClass(): string + { + return Arrayy::class; + } + + public function isStaticMethodSupported(MethodReflection $methodReflection): bool + { + return $methodReflection->getName() === 'meta'; + } + + public function getTypeFromStaticMethodCall(MethodReflection $methodReflection, StaticCall $methodCall, Scope $scope): ?Type + { + if (!$methodCall->class instanceof Name) { + return null; + } + + $className = $scope->resolveName($methodCall->class); + if (!\is_a($className, Arrayy::class, true)) { + return null; + } + + if (isset($this->types[$className])) { + return $this->types[$className]; + } + + /** @var \Arrayy\ArrayyMeta $meta */ + $meta = $className::meta(); + $properties = []; + foreach (\get_object_vars($meta) as $propertyName => $value) { + if (!\is_string($propertyName) || !\is_string($value)) { + continue; + } + + $properties[$propertyName] = new ConstantStringType($value); + } + + // Passing an empty optionalProperties list makes every inferred meta key concrete/required. + return $this->types[$className] = new ObjectShapeType($properties, []); + } +} diff --git a/tests/MetaDynamicStaticMethodReturnTypeExtensionTest.php b/tests/MetaDynamicStaticMethodReturnTypeExtensionTest.php new file mode 100644 index 0000000..2891b73 --- /dev/null +++ b/tests/MetaDynamicStaticMethodReturnTypeExtensionTest.php @@ -0,0 +1,104 @@ +getClass()); + } + + public function testIsStaticMethodSupportedOnlyForMeta(): void + { + $extension = new MetaDynamicStaticMethodReturnTypeExtension(); + + $metaMethod = $this->createMock(MethodReflection::class); + $metaMethod->method('getName')->willReturn('meta'); + + $createMethod = $this->createMock(MethodReflection::class); + $createMethod->method('getName')->willReturn('create'); + + self::assertTrue($extension->isStaticMethodSupported($metaMethod)); + self::assertFalse($extension->isStaticMethodSupported($createMethod)); + } + + public function testReturnsNullWhenStaticCallClassIsNotANameNode(): void + { + $extension = new MetaDynamicStaticMethodReturnTypeExtension(); + + $method = $this->createMock(MethodReflection::class); + $scope = $this->createMock(Scope::class); + $scope->expects(self::never())->method('resolveName'); + + $type = $extension->getTypeFromStaticMethodCall( + $method, + new StaticCall(new Variable('className'), 'meta'), + $scope + ); + + self::assertNull($type); + } + + public function testReturnsNullForNonArrayyClasses(): void + { + $extension = new MetaDynamicStaticMethodReturnTypeExtension(); + + $method = $this->createMock(MethodReflection::class); + $scope = $this->createMock(Scope::class); + $scope->expects(self::once()) + ->method('resolveName') + ->willReturn(\stdClass::class); + + $type = $extension->getTypeFromStaticMethodCall( + $method, + new StaticCall(new Name('stdClass'), 'meta'), + $scope + ); + + self::assertNull($type); + } + + public function testBuildsAndCachesMetaShapeTypes(): void + { + $extension = new MetaDynamicStaticMethodReturnTypeExtension(); + + $method = $this->createMock(MethodReflection::class); + $scope = $this->createMock(Scope::class); + $scope->expects(self::exactly(2)) + ->method('resolveName') + ->willReturn(ArrayShapeUser::class); + + $call = new StaticCall(new Name('ArrayShapeUser'), 'meta'); + + $firstType = $extension->getTypeFromStaticMethodCall($method, $call, $scope); + $secondType = $extension->getTypeFromStaticMethodCall($method, $call, $scope); + + self::assertInstanceOf(ObjectShapeType::class, $firstType); + self::assertSame($firstType, $secondType); + self::assertSame( + "object{id: 'id', firstName: 'firstName', lastName: 'lastName', city: 'city'}", + $firstType->describe(VerbosityLevel::precise()) + ); + } +} diff --git a/tests/MetaPhpStanIntegrationTest.php b/tests/MetaPhpStanIntegrationTest.php new file mode 100644 index 0000000..2f530d2 --- /dev/null +++ b/tests/MetaPhpStanIntegrationTest.php @@ -0,0 +1,102 @@ +assertFixturePassesPhpStan('MetaValidUsage.php'); + } + + public function testPhpStanAcceptsValidArrayShapeUsage(): void + { + $this->assertFixturePassesPhpStan('ArrayShapeValidUsage.php'); + } + + public function testPhpStanRejectsInvalidMetaUsage(): void + { + $output = $this->assertFixtureFailsPhpStan('MetaInvalidUsage.php'); + + static::assertStringContainsString('Access to an undefined property', $output); + static::assertStringContainsString('$ghost', $output); + static::assertStringContainsString('strlen', $output); + static::assertStringContainsString('expects string', $output); + static::assertStringContainsString('int|null', $output); + } + + public function testPhpStanRejectsInvalidArrayShapeUsage(): void + { + $output = $this->assertFixtureFailsPhpStan('ArrayShapeInvalidUsage.php'); + + static::assertStringContainsString('Parameter #1 $data of class Arrayy\tests\PHPStan\ArrayShapeUser constructor expects', $output); + static::assertStringContainsString("array{id: 'wrong', firstName: 'Lars', lastName: 'Moelleken'} given", $output); + static::assertStringContainsString("array{id: 1, firstName: 'Lars'} given", $output); + static::assertStringContainsString('Parameter #1 $data of static method Arrayy\Arrayy', $output); + } + + private function assertFixturePassesPhpStan(string $fixtureFile): void + { + [$exitCode, $stdout, $stderr] = $this->runPhpStanFixture($fixtureFile); + $output = \trim($stdout . $stderr); + + static::assertSame(0, $exitCode, $output); + } + + private function assertFixtureFailsPhpStan(string $fixtureFile): string + { + [$exitCode, $stdout, $stderr] = $this->runPhpStanFixture($fixtureFile); + $output = \trim($stdout . $stderr); + + static::assertSame(1, $exitCode, $output); + + return $output; + } + + /** + * @return array{0: int, 1: string, 2: string} + */ + private function runPhpStanFixture(string $fixtureFile): array + { + $repoRoot = \dirname(__DIR__); + $command = [ + \PHP_BINARY, + $repoRoot . '/vendor/bin/phpstan', + 'analyse', + '--no-progress', + '--error-format=raw', + '--configuration=' . $repoRoot . '/phpstan.neon', + $repoRoot . '/tests/PHPStan/' . $fixtureFile, + ]; + + $descriptorSpec = [ + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + + $process = \proc_open($command, $descriptorSpec, $pipes, $repoRoot); + static::assertIsResource($process); + + $stdout = \stream_get_contents($pipes[1]) ?: ''; + $stderr = \stream_get_contents($pipes[2]) ?: ''; + + \fclose($pipes[1]); + \fclose($pipes[2]); + + $exitCode = \proc_close($process); + + return [$exitCode, (string) $stdout, (string) $stderr]; + } +} diff --git a/tests/MetaRuntimeTest.php b/tests/MetaRuntimeTest.php new file mode 100644 index 0000000..64bf969 --- /dev/null +++ b/tests/MetaRuntimeTest.php @@ -0,0 +1,53 @@ +name => 'Düsseldorf', + $cityMeta->plz => null, + ]); + + $userMeta = ArrayShapeUser::meta(); + $user = new ArrayShapeUser([ + $userMeta->id => 1, + $userMeta->firstName => 'Lars', + $userMeta->lastName => 'Moelleken', + $userMeta->city => $city, + ]); + + static::assertSame('id', $userMeta->id); + static::assertSame('city', $userMeta->city); + static::assertSame('name', $cityMeta->name); + static::assertSame(1, $user[$userMeta->id]); + static::assertInstanceOf(ArrayShapeCity::class, $user[$userMeta->city]); + static::assertSame('Düsseldorf', $user[$userMeta->city][$cityMeta->name]); + } + + public function testArrayShapeMetaRejectsWrongRuntimeTypes(): void + { + $this->expectException(\TypeError::class); + $this->expectExceptionMessageMatches('/Invalid type: expected "id" to be of type \\{int\\}/'); + + $userMeta = ArrayShapeUser::meta(); + $user = new ArrayShapeUser([ + $userMeta->id => 1, + $userMeta->firstName => 'Lars', + $userMeta->lastName => 'Moelleken', + ]); + + $user[$userMeta->id] = 'wrong-id'; + } +} diff --git a/tests/PHPStan/ArrayShapeAccessTest.php b/tests/PHPStan/ArrayShapeAccessTest.php index 66e781a..a13539c 100644 --- a/tests/PHPStan/ArrayShapeAccessTest.php +++ b/tests/PHPStan/ArrayShapeAccessTest.php @@ -11,6 +11,19 @@ */ final class ArrayShapeAccessTest extends \PHPUnit\Framework\TestCase { + /** + * @template TCity of array{name: string, plz?: string|null} + * + * @param ArrayShapeCity|null $city + */ + private static function assertNullableCity(?ArrayShapeCity $city): void + { + } + + private static function assertNullableString(?string $value): void + { + } + public function testArrayShapeOffsetsAreTyped(): void { $user = new ArrayShapeUser([ @@ -26,12 +39,12 @@ public function testArrayShapeOffsetsAreTyped(): void \PHPStan\Testing\assertType('int|null', $user['id']); \PHPStan\Testing\assertType('string|null', $user['firstName']); \PHPStan\Testing\assertType('string|null', $user['lastName']); - \PHPStan\Testing\assertType('Arrayy\tests\PHPStan\ArrayShapeCity|null', $user['city']); + self::assertNullableCity($user['city']); if ($user['city'] !== null) { - \PHPStan\Testing\assertType('Arrayy\tests\PHPStan\ArrayShapeCity', $user['city']); - \PHPStan\Testing\assertType('string|null', $user['city']['name']); - \PHPStan\Testing\assertType('string|null', $user['city']['plz']); + self::assertNullableCity($user['city']); + self::assertNullableString($user['city']['name']); + self::assertNullableString($user['city']['plz']); } self::assertSame('Moelleken', $user['lastName']); diff --git a/tests/PHPStan/ArrayShapeInvalidUsage.php b/tests/PHPStan/ArrayShapeInvalidUsage.php new file mode 100644 index 0000000..0242b69 --- /dev/null +++ b/tests/PHPStan/ArrayShapeInvalidUsage.php @@ -0,0 +1,24 @@ + 'wrong', + 'firstName' => 'Lars', + 'lastName' => 'Moelleken', +]); + +new ArrayShapeUser([ + 'id' => 1, + 'firstName' => 'Lars', +]); + +ArrayShapeUser::create([ + 'id' => 'wrong', + 'firstName' => 'Lars', + 'lastName' => 'Moelleken', +]); diff --git a/tests/PHPStan/ArrayShapeValidUsage.php b/tests/PHPStan/ArrayShapeValidUsage.php new file mode 100644 index 0000000..ffc1d63 --- /dev/null +++ b/tests/PHPStan/ArrayShapeValidUsage.php @@ -0,0 +1,49 @@ +|null $city + */ +function assertValidArrayShapeNullableCity(?ArrayShapeCity $city): void +{ +} + +/** + * @template TCity of array{name: string, plz?: string|null} + * + * @param ArrayShapeCity $city + */ +function assertValidArrayShapeCity(ArrayShapeCity $city): void +{ +} + +function assertValidArrayShapeNullableString(?string $value): void +{ +} + +$user = new ArrayShapeUser([ + 'id' => 1, + 'firstName' => 'Lars', + 'lastName' => 'Moelleken', + 'city' => new ArrayShapeCity([ + 'name' => 'Düsseldorf', + 'plz' => null, + ]), +]); + +\PHPStan\Testing\assertType('int|null', $user['id']); +\PHPStan\Testing\assertType('string|null', $user['firstName']); +assertValidArrayShapeNullableCity($user['city']); + +if ($user['city'] !== null) { + assertValidArrayShapeCity($user['city']); + \PHPStan\Testing\assertType('string|null', $user['city']['name']); + assertValidArrayShapeNullableString($user['city']['plz']); +} diff --git a/tests/PHPStan/MetaInvalidUsage.php b/tests/PHPStan/MetaInvalidUsage.php new file mode 100644 index 0000000..0b61ef0 --- /dev/null +++ b/tests/PHPStan/MetaInvalidUsage.php @@ -0,0 +1,23 @@ +id => 1, + $userMeta->firstName => 'Lars', + $userMeta->lastName => 'Moelleken', + $userMeta->city => new ArrayShapeCity([ + $cityMeta->name => 'Düsseldorf', + $cityMeta->plz => null, + ]), +]); + +$ghost = $userMeta->ghost; +$length = \strlen($user[$userMeta->id]); diff --git a/tests/PHPStan/MetaValidUsage.php b/tests/PHPStan/MetaValidUsage.php new file mode 100644 index 0000000..12d910c --- /dev/null +++ b/tests/PHPStan/MetaValidUsage.php @@ -0,0 +1,50 @@ +|null $city + */ +function assertMetaFixtureNullableCity(?ArrayShapeCity $city): void +{ +} + +/** + * @template TCity of array{name: string, plz?: string|null} + * + * @param ArrayShapeCity $city + */ +function assertMetaFixtureCity(ArrayShapeCity $city): void +{ +} + +$cityMeta = ArrayShapeCity::meta(); +$userMeta = ArrayShapeUser::meta(); + +\PHPStan\Testing\assertType("'id'", $userMeta->id); +\PHPStan\Testing\assertType("'city'", $userMeta->city); +\PHPStan\Testing\assertType("'name'", $cityMeta->name); + +$user = new ArrayShapeUser([ + $userMeta->id => 1, + $userMeta->firstName => 'Lars', + $userMeta->lastName => 'Moelleken', + $userMeta->city => new ArrayShapeCity([ + $cityMeta->name => 'Düsseldorf', + $cityMeta->plz => null, + ]), +]); + +\PHPStan\Testing\assertType('int|null', $user[$userMeta->id]); +assertMetaFixtureNullableCity($user[$userMeta->city]); + +if ($user[$userMeta->city] !== null) { + assertMetaFixtureCity($user[$userMeta->city]); + \PHPStan\Testing\assertType('string|null', $user[$userMeta->city][$cityMeta->name]); +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index f1953e1..791b6dc 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -3,4 +3,9 @@ \error_reporting(\E_ALL); \ini_set('display_errors', '1'); +$buildLogsDir = __DIR__ . '/../build/logs'; +if (!\is_dir($buildLogsDir)) { + \mkdir($buildLogsDir, 0700, true); +} + require_once __DIR__ . '/../vendor/autoload.php';