Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 9 additions & 10 deletions src/Contract/ContractParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,11 @@ public static function parseProperty(string $className, string $propertyName): ?
}

$typeNode = $varTags[0]->type;

$aliases = [];
$templates = [];
self::parseClassLevelDocs($declaringClass, $templates, $aliases);

DocblockExtractor::extractAliases($phpDocNode, $aliases, $declaringClass);

$typeNode = self::substituteAliases($typeNode, $aliases);
Expand All @@ -180,14 +184,9 @@ public static function parseClassAliases(string $className): array
try {
/** @var class-string<object> $className */
$refClass = new \ReflectionClass($className);
$doc = $refClass->getDocComment();
if ($doc === false) {
return [];
}

$phpDocNode = DocblockExtractor::parseDocString($doc);
$aliases = [];
DocblockExtractor::extractAliases($phpDocNode, $aliases, $refClass);
$templates = [];
self::parseClassLevelDocs($refClass, $templates, $aliases);

return $aliases;
} catch (\Throwable $e) {
Expand Down Expand Up @@ -462,7 +461,7 @@ private static function substituteAliases(TypeNode $node, array $aliases): TypeN
if ($node instanceof GenericTypeNode) {
$genericType = self::substituteAliases($node->type, $aliases);
$genericTypes = array_map(
fn ($t) => self::substituteAliases($t, $aliases),
fn($t) => self::substituteAliases($t, $aliases),
$node->genericTypes
);

Expand All @@ -479,14 +478,14 @@ private static function substituteAliases(TypeNode $node, array $aliases): TypeN

if ($node instanceof UnionTypeNode) {
return new UnionTypeNode(array_map(
fn ($t) => self::substituteAliases($t, $aliases),
fn($t) => self::substituteAliases($t, $aliases),
$node->types
));
}

if ($node instanceof IntersectionTypeNode) {
return new IntersectionTypeNode(array_map(
fn ($t) => self::substituteAliases($t, $aliases),
fn($t) => self::substituteAliases($t, $aliases),
$node->types
));
}
Expand Down
14 changes: 9 additions & 5 deletions src/Contract/DocblockExtractor.php
Original file line number Diff line number Diff line change
Expand Up @@ -114,15 +114,19 @@ public static function extractAliases(
\ReflectionClass|\ReflectionFunction|\ReflectionMethod $ref
): void {
foreach ($phpDocNode->getTypeAliasTagValues() as $aliasTag) {
$aliases[$aliasTag->alias] = $aliasTag->type;
if (!isset($aliases[$aliasTag->alias])) {
$aliases[$aliasTag->alias] = $aliasTag->type;
}
}

foreach ($phpDocNode->getTypeAliasImportTagValues() as $importTag) {
$localName = $importTag->importedAs ?? $importTag->importedAlias;
$fqcnSource = SpecialTypeResolver::resolveFqcn($importTag->importedFrom->name, $ref);
$resolvedType = self::resolveImportedTypeAlias($fqcnSource, $importTag->importedAlias);
if ($resolvedType !== null) {
$aliases[$localName] = $resolvedType;
if (!isset($aliases[$localName])) {
$fqcnSource = SpecialTypeResolver::resolveFqcn($importTag->importedFrom->name, $ref);
$resolvedType = self::resolveImportedTypeAlias($fqcnSource, $importTag->importedAlias);
if ($resolvedType !== null) {
$aliases[$localName] = $resolvedType;
}
}
}
}
Expand Down
26 changes: 24 additions & 2 deletions src/Validator/UnionValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,34 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali
/** @var UnionTypeNode $unionNode */
$unionNode = $node;

$deepErrors = [];

foreach ($unionNode->types as $type) {
if ($registry->validate($value, $type, $context) === null) {
$err = $registry->validate($value, $type, $context);
if ($err === null) {
return null;
}

$msg = $err->getMessage();

if (
str_starts_with($msg, $context . '[') ||
str_starts_with($msg, $context . '->') ||
str_starts_with($msg, $context . ' is missing required') ||
str_starts_with($msg, $context . ' contains unsealed') ||
str_starts_with($msg, $context . ' property') ||
str_starts_with($msg, $context . ' key') ||
str_starts_with($msg, $context . ' value') ||
str_starts_with($msg, $context . ' extra key')
) {
$deepErrors[] = $err;
}
}

if (\count($deepErrors) > 0) {
return $deepErrors[0];
}

return ErrorFactory::createError($context . ' must be of type ' . $unionNode . ', ' . TypeFormatter::formatGivenValue($value) . ' given');
}
}
}
10 changes: 10 additions & 0 deletions tests/Fixtures/Types/Imported/ClassUsingTraitWithAlias.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

declare(strict_types=1);

namespace TypePHP\Tests\Fixtures\Types\Imported;

class ClassUsingTraitWithAlias
{
use TraitWithAlias;
}
34 changes: 34 additions & 0 deletions tests/Fixtures/Types/Imported/DbalKernelPluginLoader.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

declare(strict_types=1);

namespace TypePHP\Tests\Fixtures\Types\Imported;

/**
* @phpstan-import-type PluginInfo from KernelPluginLoader
* @phpstan-type SharedConfig array{retries: positive-int, strict: bool}
*/
class DbalKernelPluginLoader extends KernelPluginLoader
{
/**
* Tests child overriding a parent's type alias
* (SharedConfig in parent was just array{retries: int})
*
* @var SharedConfig
*/
public array $config = ['retries' => 3, 'strict' => true];

public function load(): void
{
$this->pluginInfos = [
['name' => 'SwagPayPal', 'active' => true],
];
}

public function loadBad(): void
{
$this->pluginInfos = [
['name' => 'SwagPayPal', 'active' => 'yes'],
];
}
}
15 changes: 15 additions & 0 deletions tests/Fixtures/Types/Imported/KernelPluginLoader.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace TypePHP\Tests\Fixtures\Types\Imported;

/**
* @phpstan-type PluginInfo array{name: string, active: bool}
* @phpstan-type SharedConfig array{retries: int}
*/
abstract class KernelPluginLoader
{
/** @var list<PluginInfo> */
public array $pluginInfos = [];
}
14 changes: 14 additions & 0 deletions tests/Fixtures/Types/Imported/TraitWithAlias.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

declare(strict_types=1);

namespace TypePHP\Tests\Fixtures\Types\Imported;

/**
* @phpstan-type TraitShape array{x: int, y: int}
*/
trait TraitWithAlias
{
/** @var TraitShape */
public array $coordinates = ['x' => 0, 'y' => 0];
}
46 changes: 46 additions & 0 deletions tests/TypeChecking/ArraysAndShapes/ImportedTypePropertyTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php

declare(strict_types=1);

use TypePHP\Tests\Fixtures\Types\Imported\DbalKernelPluginLoader;
use TypePHP\Tests\Fixtures\Types\Imported\ClassUsingTraitWithAlias;

describe('Class-Level Imported Types on Properties', function () {

test('resolves @phpstan-type and @phpstan-import-type on class properties', function () {
$loader = new DbalKernelPluginLoader();
$loader->load();

expect($loader->pluginInfos)->toHaveCount(1);
expect($loader->pluginInfos[0]['name'])->toBe('SwagPayPal');
});

test('fails correctly when the imported array shape is actually violated', function () {
$loader = new DbalKernelPluginLoader();

expect(fn() => $loader->loadBad())
->toThrow(\TypeError::class, "['active']");
});

test('resolves overridden aliases in child class without breaking parent inheritance', function () {
$loader = new DbalKernelPluginLoader();

$loader->config = ['retries' => 5, 'strict' => false];
expect($loader->config)->toBe(['retries' => 5, 'strict' => false]);

expect(fn() => $loader->config = ['retries' => -1, 'strict' => false])
->toThrow(\TypeError::class, "['retries'] must be of type positive-int");
});

test('resolves aliases defined on traits applied to properties inside the trait', function () {
$instance = new ClassUsingTraitWithAlias();

expect($instance->coordinates)->toBe(['x' => 0, 'y' => 0]);

$instance->coordinates = ['x' => 10, 'y' => 20];
expect($instance->coordinates['x'])->toBe(10);

expect(fn() => $instance->coordinates = ['x' => 10, 'y' => 'invalid'])
->toThrow(\TypeError::class, "['y'] must be of type int");
});
});
135 changes: 135 additions & 0 deletions tests/TypeChecking/ArraysAndShapes/UnionErrorBubblingTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
<?php

declare(strict_types=1);

/**
* @param array{id: int, tags: list<string|int>}|null $payload
*/
function testDeepUnionError(mixed $payload): bool
{
return true;
}

/**
* @return array{name: string, args: list<string|int|false>}|null
*/
function testAttributeCompilerSim(): ?array
{
return [
'name' => 'Field',
'args' => ['column', 'property', new \stdClass()],
];
}

/**
* @param object{id: positive-int, profile: object{name: non-empty-string}}|null $user
*/
function testDeepObjectShapeUnion(mixed $user): bool
{
return true;
}

/**
* @param object{id: int, role: string}|null $data
*/
function testMissingObjectPropertyUnion(mixed $data): bool
{
return true;
}

/**
* @param object{name: string}|null $data
*/
function testUninitializedObjectPropertyUnion(mixed $data): bool
{
return true;
}

/**
* @param (array{type: 'A', data: array{score: positive-int}} | array{type: 'B', data: array{code: non-empty-string}})|null $discriminated
*/
function testDiscriminatedUnionDeepError(mixed $discriminated): bool
{
return true;
}

class PropertyUnionFixture
{
/**
* @var array{config: array{enabled: bool}}|null
*/
public ?array $settings = null;
}

describe('Union Deep Error Bubbling', function () {
test('surfaces deep array shape error instead of generic union error', function () {
expect(fn() => testDeepUnionError(['id' => 10, 'tags' => ['hello', false]]))
->toThrow(\TypeError::class, "Argument \$payload['tags'][1] must be of type (string | int)");
});

test('surfaces deep return shape error mimicking AttributeEntityCompiler', function () {
expect(fn() => testAttributeCompilerSim())
->toThrow(\TypeError::class, "Return value['args'][2] must be of type (string | int | false)");
});

test('surfaces missing key error from array shape inside union', function () {
expect(fn() => testDeepUnionError(['id' => 10]))
->toThrow(\TypeError::class, "Argument \$payload is missing required key 'tags'");
});

test('surfaces deep object shape error inside union using anonymous class', function () {
$user = new class {
public int $id = 10;
public object $profile;

public function __construct() {
$this->profile = new class {
public string $name = '';
};
}
};

expect(fn() => testDeepObjectShapeUnion($user))
->toThrow(\TypeError::class, "Argument \$user->profile->name must be of type non-empty-string");
});

test('surfaces missing property error on object shape inside union using anonymous class', function () {
$obj = new class {
public int $id = 10;
};

expect(fn() => testMissingObjectPropertyUnion($obj))
->toThrow(\TypeError::class, "Argument \$data is missing required property 'role'");
});

test('surfaces uninitialized property error on object shape inside union using anonymous class', function () {
$obj = new class {
public string $name;
};

expect(fn() => testUninitializedObjectPropertyUnion($obj))
->toThrow(\TypeError::class, "Argument \$data property 'name' is uninitialized");
});

test('surfaces deep error in nested discriminated union shape', function () {
$payload = [
'type' => 'A',
'data' => ['score' => -5],
];

expect(fn() => testDiscriminatedUnionDeepError($payload))
->toThrow(\TypeError::class, "['score'] must be of type positive-int");
});

test('surfaces deep error on class property with union shape', function () {
$fixture = new PropertyUnionFixture();

expect(fn() => $fixture->settings = ['config' => ['enabled' => 'not_a_bool']])
->toThrow(\TypeError::class, "Property PropertyUnionFixture::\$settings['config']['enabled'] must be of type bool");
});

test('falls back gracefully to generic union error when no deep branch matches structure', function () {
expect(fn() => testDeepUnionError('string_instead_of_array'))
->toThrow(\TypeError::class, "must be of type (array{id: int, tags: list<(string | int)>} | null)");
});
});
Loading