From dcd4e34ee6ede11153268c04c3f00fa583bd8dac Mon Sep 17 00:00:00 2001 From: andrew Date: Mon, 17 Apr 2023 16:24:21 +0300 Subject: [PATCH 1/4] Support anonymous object template replacement --- .../Type/Comparator/KeyedArrayComparator.php | 2 +- .../Type/TemplateStandinTypeReplacer.php | 52 +++++++++++++++++++ .../Type/Atomic/TObjectWithProperties.php | 2 +- 3 files changed, 54 insertions(+), 2 deletions(-) diff --git a/src/Psalm/Internal/Type/Comparator/KeyedArrayComparator.php b/src/Psalm/Internal/Type/Comparator/KeyedArrayComparator.php index 9124d383b34..11d11f44fbe 100644 --- a/src/Psalm/Internal/Type/Comparator/KeyedArrayComparator.php +++ b/src/Psalm/Internal/Type/Comparator/KeyedArrayComparator.php @@ -306,7 +306,7 @@ public static function isContainedByObjectWithProperties( } $property_declaring_class = (string) $codebase->properties->getDeclaringClassForProperty( - $input_type_part . '::$' . $property_name, + $input_type_part->value . '::$' . $property_name, true, ); diff --git a/src/Psalm/Internal/Type/TemplateStandinTypeReplacer.php b/src/Psalm/Internal/Type/TemplateStandinTypeReplacer.php index a2321886dca..cc941d579f9 100644 --- a/src/Psalm/Internal/Type/TemplateStandinTypeReplacer.php +++ b/src/Psalm/Internal/Type/TemplateStandinTypeReplacer.php @@ -581,6 +581,58 @@ private static function findMatchingAtomicTypesForTemplate( } } + if ($atomic_input_type instanceof TNamedObject + && $codebase->classlike_storage_provider->has($atomic_input_type->value) + && $base_type instanceof TObjectWithProperties + ) { + $storage = $codebase->classlike_storage_provider->get($atomic_input_type->value); + $inferred_lower_bounds = []; + + if ($atomic_input_type 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] = + $atomic_input_type->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($base_type->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, + ); + + $matching_atomic_types[$replaced_object->getId()] = $replaced_object->getSingleAtomic(); + + 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]; From ee0395247f97cdb95b1bd93c165026e92f0d69ce Mon Sep 17 00:00:00 2001 From: adrew Date: Mon, 17 Apr 2023 20:48:44 +0300 Subject: [PATCH 2/4] Don't expand template for property fetch on TObjectWithProperties --- .../Statements/Expression/Fetch/AtomicPropertyFetchAnalyzer.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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, From 6614767d76b7eda60c2911330f74eb48169a86cd Mon Sep 17 00:00:00 2001 From: adrew Date: Mon, 17 Apr 2023 22:12:21 +0300 Subject: [PATCH 3/4] Add KeyedArrayComparator::coerceToObjectWithProperties --- .../Type/Comparator/KeyedArrayComparator.php | 93 ++++++++++++++----- .../Type/TemplateStandinTypeReplacer.php | 49 ++-------- 2 files changed, 76 insertions(+), 66 deletions(-) diff --git a/src/Psalm/Internal/Type/Comparator/KeyedArrayComparator.php b/src/Psalm/Internal/Type/Comparator/KeyedArrayComparator.php index 11d11f44fbe..773237ad38a 100644 --- a/src/Psalm/Internal/Type/Comparator/KeyedArrayComparator.php +++ b/src/Psalm/Internal/Type/Comparator/KeyedArrayComparator.php @@ -3,12 +3,16 @@ 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->value . '::$' . $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 cc941d579f9..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; @@ -582,53 +583,17 @@ private static function findMatchingAtomicTypesForTemplate( } if ($atomic_input_type instanceof TNamedObject - && $codebase->classlike_storage_provider->has($atomic_input_type->value) && $base_type instanceof TObjectWithProperties ) { - $storage = $codebase->classlike_storage_provider->get($atomic_input_type->value); - $inferred_lower_bounds = []; - - if ($atomic_input_type 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] = - $atomic_input_type->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($base_type->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, - ), + $object_with_keys = KeyedArrayComparator::coerceToObjectWithProperties( $codebase, + $atomic_input_type, + $base_type, ); - $matching_atomic_types[$replaced_object->getId()] = $replaced_object->getSingleAtomic(); + if ($object_with_keys) { + $matching_atomic_types[$object_with_keys->getId()] = $object_with_keys; + } continue; } From aa3b2f2e1d4099b37fc48839aef833a92f4da164 Mon Sep 17 00:00:00 2001 From: adrew Date: Mon, 17 Apr 2023 22:17:20 +0300 Subject: [PATCH 4/4] Test anonymous object template replacement --- .../Type/Comparator/KeyedArrayComparator.php | 2 +- tests/FunctionCallTest.php | 128 ++++++++++++++++++ 2 files changed, 129 insertions(+), 1 deletion(-) diff --git a/src/Psalm/Internal/Type/Comparator/KeyedArrayComparator.php b/src/Psalm/Internal/Type/Comparator/KeyedArrayComparator.php index 773237ad38a..c9d7aad5842 100644 --- a/src/Psalm/Internal/Type/Comparator/KeyedArrayComparator.php +++ b/src/Psalm/Internal/Type/Comparator/KeyedArrayComparator.php @@ -11,8 +11,8 @@ 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; 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' => '