diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/Fetch/AtomicPropertyFetchAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/Fetch/AtomicPropertyFetchAnalyzer.php index dce178f32d4..0a6f7e4529e 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/Fetch/AtomicPropertyFetchAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/Fetch/AtomicPropertyFetchAnalyzer.php @@ -135,7 +135,7 @@ public static function analyze( true, false, true, - true, + false, true, ), $stmt_type, diff --git a/src/Psalm/Internal/Type/Comparator/KeyedArrayComparator.php b/src/Psalm/Internal/Type/Comparator/KeyedArrayComparator.php index 9124d383b34..c9d7aad5842 100644 --- a/src/Psalm/Internal/Type/Comparator/KeyedArrayComparator.php +++ b/src/Psalm/Internal/Type/Comparator/KeyedArrayComparator.php @@ -3,11 +3,15 @@ namespace Psalm\Internal\Type\Comparator; use Psalm\Codebase; +use Psalm\Internal\Type\TemplateInferredTypeReplacer; +use Psalm\Internal\Type\TemplateResult; use Psalm\Type; use Psalm\Type\Atomic; +use Psalm\Type\Atomic\TGenericObject; use Psalm\Type\Atomic\TKeyedArray; use Psalm\Type\Atomic\TNamedObject; use Psalm\Type\Atomic\TObjectWithProperties; +use Psalm\Type\Union; use function array_keys; use function is_string; @@ -285,36 +289,20 @@ public static function isContainedByObjectWithProperties( ): bool { $all_types_contain = true; - foreach ($container_type_part->properties as $property_name => $container_property_type) { - if (!is_string($property_name)) { - continue; - } - - if (!$codebase->classlikes->classOrInterfaceExists($input_type_part->value)) { - $all_types_contain = false; + $input_object_with_keys = self::coerceToObjectWithProperties( + $codebase, + $input_type_part, + $container_type_part, + ); - continue; - } - - if (!$codebase->properties->propertyExists( - $input_type_part->value . '::$' . $property_name, - true, - )) { + foreach ($container_type_part->properties as $property_name => $container_property_type) { + if (!$input_object_with_keys || !isset($input_object_with_keys->properties[$property_name])) { $all_types_contain = false; continue; } - $property_declaring_class = (string) $codebase->properties->getDeclaringClassForProperty( - $input_type_part . '::$' . $property_name, - true, - ); - - $class_storage = $codebase->classlike_storage_provider->get($property_declaring_class); - - $input_property_storage = $class_storage->properties[$property_name]; - - $input_property_type = $input_property_storage->type ?: Type::getMixed(); + $input_property_type = $input_object_with_keys->properties[$property_name]; $property_type_comparison = new TypeComparisonResult(); @@ -354,4 +342,61 @@ public static function isContainedByObjectWithProperties( return $all_types_contain; } + + public static function coerceToObjectWithProperties( + Codebase $codebase, + TNamedObject $input_type_part, + TObjectWithProperties $container_type_part + ): ?TObjectWithProperties { + $storage = $codebase->classlikes->getStorageFor($input_type_part->value); + + if (!$storage) { + return null; + } + + $inferred_lower_bounds = []; + + if ($input_type_part instanceof TGenericObject) { + foreach ($storage->template_types ?? [] as $template_name => $templates) { + foreach (array_keys($templates) as $offset => $defining_at) { + $inferred_lower_bounds[$template_name][$defining_at] = + $input_type_part->type_params[$offset]; + } + } + } + + foreach ($storage->template_extended_params ?? [] as $defining_at => $templates) { + foreach ($templates as $template_name => $template_atomic) { + $inferred_lower_bounds[$template_name][$defining_at] = $template_atomic; + } + } + + $properties = []; + + foreach ($storage->appearing_property_ids as $property_name => $property_id) { + if (!isset($container_type_part->properties[$property_name])) { + continue; + } + + $property_type = $codebase->properties->hasStorage($property_id) + ? $codebase->properties->getStorage($property_id)->type + : null; + + $properties[$property_name] = $property_type ?? Type::getMixed(); + } + + $replaced_object = TemplateInferredTypeReplacer::replace( + new Union([ + new TObjectWithProperties($properties), + ]), + new TemplateResult( + $storage->template_types ?? [], + $inferred_lower_bounds, + ), + $codebase, + ); + + /** @var TObjectWithProperties */ + return $replaced_object->getSingleAtomic(); + } } diff --git a/src/Psalm/Internal/Type/TemplateStandinTypeReplacer.php b/src/Psalm/Internal/Type/TemplateStandinTypeReplacer.php index a2321886dca..ca2bbe86e34 100644 --- a/src/Psalm/Internal/Type/TemplateStandinTypeReplacer.php +++ b/src/Psalm/Internal/Type/TemplateStandinTypeReplacer.php @@ -7,6 +7,7 @@ use Psalm\Internal\Analyzer\StatementsAnalyzer; use Psalm\Internal\Codebase\Methods; use Psalm\Internal\Type\Comparator\CallableTypeComparator; +use Psalm\Internal\Type\Comparator\KeyedArrayComparator; use Psalm\Internal\Type\Comparator\UnionTypeComparator; use Psalm\Type; use Psalm\Type\Atomic; @@ -581,6 +582,22 @@ private static function findMatchingAtomicTypesForTemplate( } } + if ($atomic_input_type instanceof TNamedObject + && $base_type instanceof TObjectWithProperties + ) { + $object_with_keys = KeyedArrayComparator::coerceToObjectWithProperties( + $codebase, + $atomic_input_type, + $base_type, + ); + + if ($object_with_keys) { + $matching_atomic_types[$object_with_keys->getId()] = $object_with_keys; + } + + continue; + } + if ($atomic_input_type instanceof TTemplateParam) { $matching_atomic_types = array_merge( $matching_atomic_types, diff --git a/src/Psalm/Type/Atomic/TObjectWithProperties.php b/src/Psalm/Type/Atomic/TObjectWithProperties.php index 3fd8510b784..cae5be7e7f7 100644 --- a/src/Psalm/Type/Atomic/TObjectWithProperties.php +++ b/src/Psalm/Type/Atomic/TObjectWithProperties.php @@ -235,7 +235,7 @@ public function replaceTemplateTypesWithStandins( foreach ($this->properties as $offset => $property) { $input_type_param = null; - if ($input_type instanceof TKeyedArray + if ($input_type instanceof TObjectWithProperties && isset($input_type->properties[$offset]) ) { $input_type_param = $input_type->properties[$offset]; diff --git a/tests/FunctionCallTest.php b/tests/FunctionCallTest.php index 8442696e695..96c960031c7 100644 --- a/tests/FunctionCallTest.php +++ b/tests/FunctionCallTest.php @@ -80,6 +80,134 @@ function mergeIterable(iterable $lhs, iterable $rhs): iterable '$iterable===' => 'iterable', ], ], + 'inferTypeFromAnonymousObjectWithTemplatedProperty' => [ + 'code' => 'value; + } + /** + * @template T + * @param object{value: object{value: T}} $object + * @return T + */ + function getNestedValue(object $object): mixed + { + return $object->value->value; + } + $object = new Value(new Value(42)); + $value = getValue($object); + $nestedValue = getNestedValue($object);', + 'assertions' => [ + '$value===' => 'Value<42>', + '$nestedValue===' => '42', + ], + 'ignored_issues' => [], + 'php_version' => '8.1', + ], + 'inferTypeFromAnonymousObjectWithTemplatedPropertyFromTemplatedAncestor' => [ + 'code' => ' + */ + final class ConcreteValue extends AbstractValue + { + /** + * @param TValue $value + */ + public function __construct(mixed $value) + { + parent::__construct($value); + } + } + /** + * @template T + * @param object{value: T} $object + * @return T + */ + function getValue(object $object): mixed + { + return $object->value; + } + /** + * @template T + * @param object{value: object{value: T}} $object + * @return T + */ + function getNestedValue(object $object): mixed + { + return $object->value->value; + } + $object = new ConcreteValue(new ConcreteValue(42)); + $value = getValue($object); + $nestedValue = getNestedValue($object);', + 'assertions' => [ + '$value===' => 'ConcreteValue<42>', + '$nestedValue===' => '42', + ], + 'ignored_issues' => [], + 'php_version' => '8.1', + ], + 'inferTypeFromAnonymousObjectWithTemplatedPropertyFromConcreteAncestor' => [ + 'code' => ' */ + final class IntValue extends AbstractValue {} + final class Nested + { + public function __construct(public readonly IntValue $value) {} + } + /** + * @template T + * @param object{value: T} $object + * @return T + */ + function getValue(object $object): mixed + { + return $object->value; + } + /** + * @template T + * @param object{value: object{value: T}} $object + * @return T + */ + function getNestedValue(object $object): mixed + { + return $object->value->value; + } + $object = new Nested(new IntValue(42)); + $value = getValue($object); + $nestedValue = getNestedValue($object);', + 'assertions' => [ + '$value===' => 'IntValue', + '$nestedValue===' => 'int', + ], + 'ignored_issues' => [], + 'php_version' => '8.1', + ], 'countShapedArrays' => [ 'code' => '