Skip to content

Commit adda744

Browse files
authored
Merge pull request #165 from voku/copilot/add-tests-and-validate-phpstan
Expand PHPStan coverage for `meta()` and array-shape-backed `Arrayy` models
2 parents d1a0c6c + 011cf0e commit adda744

15 files changed

Lines changed: 502 additions & 6 deletions

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
- fix `average()` so non-numeric values no longer error on modern PHP versions
66
- make `changeKeyCase()` Unicode case conversion deterministic across PHP 8.0–8.5
77
- strengthen native property type checks, array-shape contracts, and regression coverage across Json mapper and collection helpers
8+
- add PHPStan + runtime coverage for `meta()` with array-shape-backed models and document the recommended usage in the README
9+
- stabilize the full PHPUnit / PHPStan CI matrix across PHP 8.0–8.5 for both lowest and current dependency sets
810
- remove stale PHP 8-only compatibility branches, clean up PHPStan ignores, and refresh the PHP 8.0+ docs/CI matrix
911

1012
### 7.10.0 (2026-04-24)

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ $arrayy->Lars->lastname; // 'Müller'
118118

119119
## PhpDoc array-shape / property checking
120120

121-
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.
121+
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.
122122

123123
```php
124124
/**

build/docs/base.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ $arrayy->Lars->lastname; // 'Müller'
117117

118118
## PhpDoc array-shape / property checking
119119

120-
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.
120+
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.
121121

122122
```php
123123
/**

phpstan.neon

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,9 @@ parameters:
44
paths:
55
- %currentWorkingDirectory%/src/
66
- %currentWorkingDirectory%/tests/
7+
8+
services:
9+
-
10+
class: Arrayy\PHPStan\MetaDynamicStaticMethodReturnTypeExtension
11+
tags:
12+
- phpstan.broker.dynamicStaticMethodReturnTypeExtension

src/Arrayy.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ class Arrayy extends \ArrayObject implements \IteratorAggregate, \ArrayAccess, \
117117
* true, otherwise this option didn't not work anyway.
118118
* </p>
119119
*
120+
* @phpstan-param TData|self<TKey,T,TData>|\Traversable<TKey,T>|callable|object|scalar|null $data
120121
* @phpstan-param class-string<\Arrayy\ArrayyIterator<TKey,T>> $iteratorClass
121122
*/
122123
public function __construct(
@@ -1752,6 +1753,7 @@ public function countValues(): self
17521753
* @return static
17531754
* <p>(Immutable) Returns an new instance of the Arrayy object.</p>
17541755
*
1756+
* @phpstan-param TData|self<TKey,T,TData>|\Traversable<TKey,T>|callable|object|scalar|null $data
17551757
* @phpstan-param class-string<\Arrayy\ArrayyIterator<TKey,T>> $iteratorClass
17561758
* @phpstan-return static
17571759
* @psalm-mutation-free
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Arrayy\PHPStan;
6+
7+
use Arrayy\Arrayy;
8+
use PhpParser\Node\Expr\StaticCall;
9+
use PhpParser\Node\Name;
10+
use PHPStan\Analyser\Scope;
11+
use PHPStan\Reflection\MethodReflection;
12+
use PHPStan\Type\Constant\ConstantStringType;
13+
use PHPStan\Type\DynamicStaticMethodReturnTypeExtension;
14+
use PHPStan\Type\ObjectShapeType;
15+
use PHPStan\Type\Type;
16+
17+
final class MetaDynamicStaticMethodReturnTypeExtension implements DynamicStaticMethodReturnTypeExtension
18+
{
19+
/**
20+
* @var array<class-string, ObjectShapeType>
21+
*/
22+
private array $types = [];
23+
24+
public function getClass(): string
25+
{
26+
return Arrayy::class;
27+
}
28+
29+
public function isStaticMethodSupported(MethodReflection $methodReflection): bool
30+
{
31+
return $methodReflection->getName() === 'meta';
32+
}
33+
34+
public function getTypeFromStaticMethodCall(MethodReflection $methodReflection, StaticCall $methodCall, Scope $scope): ?Type
35+
{
36+
if (!$methodCall->class instanceof Name) {
37+
return null;
38+
}
39+
40+
$className = $scope->resolveName($methodCall->class);
41+
if (!\is_a($className, Arrayy::class, true)) {
42+
return null;
43+
}
44+
45+
if (isset($this->types[$className])) {
46+
return $this->types[$className];
47+
}
48+
49+
/** @var \Arrayy\ArrayyMeta $meta */
50+
$meta = $className::meta();
51+
$properties = [];
52+
foreach (\get_object_vars($meta) as $propertyName => $value) {
53+
if (!\is_string($propertyName) || !\is_string($value)) {
54+
continue;
55+
}
56+
57+
$properties[$propertyName] = new ConstantStringType($value);
58+
}
59+
60+
// Passing an empty optionalProperties list makes every inferred meta key concrete/required.
61+
return $this->types[$className] = new ObjectShapeType($properties, []);
62+
}
63+
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Arrayy\tests;
6+
7+
use Arrayy\PHPStan\MetaDynamicStaticMethodReturnTypeExtension;
8+
use Arrayy\tests\PHPStan\ArrayShapeUser;
9+
use PhpParser\Node\Expr\StaticCall;
10+
use PhpParser\Node\Expr\Variable;
11+
use PhpParser\Node\Name;
12+
use PHPStan\Analyser\Scope;
13+
use PHPStan\Reflection\MethodReflection;
14+
use PHPStan\Type\ObjectShapeType;
15+
use PHPStan\Type\VerbosityLevel;
16+
17+
require_once __DIR__ . '/PHPStan/ArrayShapeCity.php';
18+
require_once __DIR__ . '/PHPStan/ArrayShapeUser.php';
19+
20+
/**
21+
* @internal
22+
*/
23+
final class MetaDynamicStaticMethodReturnTypeExtensionTest extends \PHPUnit\Framework\TestCase
24+
{
25+
public function testGetClassTargetsArrayy(): void
26+
{
27+
$extension = new MetaDynamicStaticMethodReturnTypeExtension();
28+
29+
self::assertSame(\Arrayy\Arrayy::class, $extension->getClass());
30+
}
31+
32+
public function testIsStaticMethodSupportedOnlyForMeta(): void
33+
{
34+
$extension = new MetaDynamicStaticMethodReturnTypeExtension();
35+
36+
$metaMethod = $this->createMock(MethodReflection::class);
37+
$metaMethod->method('getName')->willReturn('meta');
38+
39+
$createMethod = $this->createMock(MethodReflection::class);
40+
$createMethod->method('getName')->willReturn('create');
41+
42+
self::assertTrue($extension->isStaticMethodSupported($metaMethod));
43+
self::assertFalse($extension->isStaticMethodSupported($createMethod));
44+
}
45+
46+
public function testReturnsNullWhenStaticCallClassIsNotANameNode(): void
47+
{
48+
$extension = new MetaDynamicStaticMethodReturnTypeExtension();
49+
50+
$method = $this->createMock(MethodReflection::class);
51+
$scope = $this->createMock(Scope::class);
52+
$scope->expects(self::never())->method('resolveName');
53+
54+
$type = $extension->getTypeFromStaticMethodCall(
55+
$method,
56+
new StaticCall(new Variable('className'), 'meta'),
57+
$scope
58+
);
59+
60+
self::assertNull($type);
61+
}
62+
63+
public function testReturnsNullForNonArrayyClasses(): void
64+
{
65+
$extension = new MetaDynamicStaticMethodReturnTypeExtension();
66+
67+
$method = $this->createMock(MethodReflection::class);
68+
$scope = $this->createMock(Scope::class);
69+
$scope->expects(self::once())
70+
->method('resolveName')
71+
->willReturn(\stdClass::class);
72+
73+
$type = $extension->getTypeFromStaticMethodCall(
74+
$method,
75+
new StaticCall(new Name('stdClass'), 'meta'),
76+
$scope
77+
);
78+
79+
self::assertNull($type);
80+
}
81+
82+
public function testBuildsAndCachesMetaShapeTypes(): void
83+
{
84+
$extension = new MetaDynamicStaticMethodReturnTypeExtension();
85+
86+
$method = $this->createMock(MethodReflection::class);
87+
$scope = $this->createMock(Scope::class);
88+
$scope->expects(self::exactly(2))
89+
->method('resolveName')
90+
->willReturn(ArrayShapeUser::class);
91+
92+
$call = new StaticCall(new Name('ArrayShapeUser'), 'meta');
93+
94+
$firstType = $extension->getTypeFromStaticMethodCall($method, $call, $scope);
95+
$secondType = $extension->getTypeFromStaticMethodCall($method, $call, $scope);
96+
97+
self::assertInstanceOf(ObjectShapeType::class, $firstType);
98+
self::assertSame($firstType, $secondType);
99+
self::assertSame(
100+
"object{id: 'id', firstName: 'firstName', lastName: 'lastName', city: 'city'}",
101+
$firstType->describe(VerbosityLevel::precise())
102+
);
103+
}
104+
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Arrayy\tests;
6+
7+
/**
8+
* @internal
9+
*/
10+
final class MetaPhpStanIntegrationTest extends \PHPUnit\Framework\TestCase
11+
{
12+
protected function setUp(): void
13+
{
14+
if (!\function_exists('proc_open')) {
15+
static::markTestSkipped('proc_open() is required to execute PHPStan.');
16+
}
17+
}
18+
19+
public function testPhpStanAcceptsValidMetaUsage(): void
20+
{
21+
$this->assertFixturePassesPhpStan('MetaValidUsage.php');
22+
}
23+
24+
public function testPhpStanAcceptsValidArrayShapeUsage(): void
25+
{
26+
$this->assertFixturePassesPhpStan('ArrayShapeValidUsage.php');
27+
}
28+
29+
public function testPhpStanRejectsInvalidMetaUsage(): void
30+
{
31+
$output = $this->assertFixtureFailsPhpStan('MetaInvalidUsage.php');
32+
33+
static::assertStringContainsString('Access to an undefined property', $output);
34+
static::assertStringContainsString('$ghost', $output);
35+
static::assertStringContainsString('strlen', $output);
36+
static::assertStringContainsString('expects string', $output);
37+
static::assertStringContainsString('int|null', $output);
38+
}
39+
40+
public function testPhpStanRejectsInvalidArrayShapeUsage(): void
41+
{
42+
$output = $this->assertFixtureFailsPhpStan('ArrayShapeInvalidUsage.php');
43+
44+
static::assertStringContainsString('Parameter #1 $data of class Arrayy\tests\PHPStan\ArrayShapeUser constructor expects', $output);
45+
static::assertStringContainsString("array{id: 'wrong', firstName: 'Lars', lastName: 'Moelleken'} given", $output);
46+
static::assertStringContainsString("array{id: 1, firstName: 'Lars'} given", $output);
47+
static::assertStringContainsString('Parameter #1 $data of static method Arrayy\Arrayy', $output);
48+
}
49+
50+
private function assertFixturePassesPhpStan(string $fixtureFile): void
51+
{
52+
[$exitCode, $stdout, $stderr] = $this->runPhpStanFixture($fixtureFile);
53+
$output = \trim($stdout . $stderr);
54+
55+
static::assertSame(0, $exitCode, $output);
56+
}
57+
58+
private function assertFixtureFailsPhpStan(string $fixtureFile): string
59+
{
60+
[$exitCode, $stdout, $stderr] = $this->runPhpStanFixture($fixtureFile);
61+
$output = \trim($stdout . $stderr);
62+
63+
static::assertSame(1, $exitCode, $output);
64+
65+
return $output;
66+
}
67+
68+
/**
69+
* @return array{0: int, 1: string, 2: string}
70+
*/
71+
private function runPhpStanFixture(string $fixtureFile): array
72+
{
73+
$repoRoot = \dirname(__DIR__);
74+
$command = [
75+
\PHP_BINARY,
76+
$repoRoot . '/vendor/bin/phpstan',
77+
'analyse',
78+
'--no-progress',
79+
'--error-format=raw',
80+
'--configuration=' . $repoRoot . '/phpstan.neon',
81+
$repoRoot . '/tests/PHPStan/' . $fixtureFile,
82+
];
83+
84+
$descriptorSpec = [
85+
1 => ['pipe', 'w'],
86+
2 => ['pipe', 'w'],
87+
];
88+
89+
$process = \proc_open($command, $descriptorSpec, $pipes, $repoRoot);
90+
static::assertIsResource($process);
91+
92+
$stdout = \stream_get_contents($pipes[1]) ?: '';
93+
$stderr = \stream_get_contents($pipes[2]) ?: '';
94+
95+
\fclose($pipes[1]);
96+
\fclose($pipes[2]);
97+
98+
$exitCode = \proc_close($process);
99+
100+
return [$exitCode, (string) $stdout, (string) $stderr];
101+
}
102+
}

tests/MetaRuntimeTest.php

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Arrayy\tests;
6+
7+
use Arrayy\tests\PHPStan\ArrayShapeCity;
8+
use Arrayy\tests\PHPStan\ArrayShapeUser;
9+
10+
/**
11+
* @internal
12+
*/
13+
final class MetaRuntimeTest extends \PHPUnit\Framework\TestCase
14+
{
15+
public function testArrayShapeMetaSupportsNestedRuntimeAccess(): void
16+
{
17+
$cityMeta = ArrayShapeCity::meta();
18+
$city = new ArrayShapeCity([
19+
$cityMeta->name => 'Düsseldorf',
20+
$cityMeta->plz => null,
21+
]);
22+
23+
$userMeta = ArrayShapeUser::meta();
24+
$user = new ArrayShapeUser([
25+
$userMeta->id => 1,
26+
$userMeta->firstName => 'Lars',
27+
$userMeta->lastName => 'Moelleken',
28+
$userMeta->city => $city,
29+
]);
30+
31+
static::assertSame('id', $userMeta->id);
32+
static::assertSame('city', $userMeta->city);
33+
static::assertSame('name', $cityMeta->name);
34+
static::assertSame(1, $user[$userMeta->id]);
35+
static::assertInstanceOf(ArrayShapeCity::class, $user[$userMeta->city]);
36+
static::assertSame('Düsseldorf', $user[$userMeta->city][$cityMeta->name]);
37+
}
38+
39+
public function testArrayShapeMetaRejectsWrongRuntimeTypes(): void
40+
{
41+
$this->expectException(\TypeError::class);
42+
$this->expectExceptionMessageMatches('/Invalid type: expected "id" to be of type \\{int\\}/');
43+
44+
$userMeta = ArrayShapeUser::meta();
45+
$user = new ArrayShapeUser([
46+
$userMeta->id => 1,
47+
$userMeta->firstName => 'Lars',
48+
$userMeta->lastName => 'Moelleken',
49+
]);
50+
51+
$user[$userMeta->id] = 'wrong-id';
52+
}
53+
}

0 commit comments

Comments
 (0)