diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 6906e22da9..b18d053ae7 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1734,7 +1734,7 @@ parameters: - rawMessage: 'Doing instanceof PHPStan\Type\ObjectShapeType is error-prone and deprecated. Use Type::isObject() and Type::hasProperty() instead.' identifier: phpstanApi.instanceofType - count: 2 + count: 4 path: src/Type/TypeCombinator.php - diff --git a/src/Type/ObjectShapeType.php b/src/Type/ObjectShapeType.php index 9dedcfa148..78a89a87b5 100644 --- a/src/Type/ObjectShapeType.php +++ b/src/Type/ObjectShapeType.php @@ -10,6 +10,7 @@ use PHPStan\PhpDocParser\Ast\Type\ObjectShapeNode; use PHPStan\PhpDocParser\Ast\Type\TypeNode; use PHPStan\Reflection\ClassMemberAccessAnswerer; +use PHPStan\Reflection\Dummy\DummyPropertyReflection; use PHPStan\Reflection\ExtendedPropertyReflection; use PHPStan\Reflection\MissingPropertyFromReflectionException; use PHPStan\Reflection\Php\UniversalObjectCratesClassReflectionExtension; @@ -113,15 +114,14 @@ public function getUnresolvedPropertyPrototype(string $propertyName, ClassMember public function hasInstanceProperty(string $propertyName): TrinaryLogic { - if (!array_key_exists($propertyName, $this->properties)) { - return TrinaryLogic::createNo(); + if ( + array_key_exists($propertyName, $this->properties) + && !in_array($propertyName, $this->optionalProperties, true) + ) { + return TrinaryLogic::createYes(); } - if (in_array($propertyName, $this->optionalProperties, true)) { - return TrinaryLogic::createMaybe(); - } - - return TrinaryLogic::createYes(); + return TrinaryLogic::createMaybe(); } public function getInstanceProperty(string $propertyName, ClassMemberAccessAnswerer $scope): ExtendedPropertyReflection @@ -131,11 +131,12 @@ public function getInstanceProperty(string $propertyName, ClassMemberAccessAnswe public function getUnresolvedInstancePropertyPrototype(string $propertyName, ClassMemberAccessAnswerer $scope): UnresolvedPropertyPrototypeReflection { - if (!array_key_exists($propertyName, $this->properties)) { - throw new ShouldNotHappenException(); + if (array_key_exists($propertyName, $this->properties)) { + $property = new ObjectShapePropertyReflection($propertyName, $this->properties[$propertyName]); + } else { + $property = new DummyPropertyReflection($propertyName); } - $property = new ObjectShapePropertyReflection($propertyName, $this->properties[$propertyName]); return new CallbackUnresolvedPropertyPrototypeReflection( $property, $property->getDeclaringClass(), diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index e7a0d6bdf5..28f3ec5354 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -26,6 +26,7 @@ use PHPStan\Type\Generic\TemplateType; use PHPStan\Type\Generic\TemplateTypeFactory; use PHPStan\Type\Generic\TemplateUnionType; +use function array_filter; use function array_key_exists; use function array_key_first; use function array_merge; @@ -36,6 +37,7 @@ use function get_class; use function in_array; use function is_int; +use function ksort; use function sprintf; use function usort; use const PHP_INT_MAX; @@ -1300,6 +1302,81 @@ public static function intersect(Type ...$types): Type } } + if ($types[$i] instanceof ObjectShapeType && $types[$j] instanceof ObjectShapeType) { + $mergedProperties = $types[$i]->getProperties(); + $mergedOptionalProperties = $types[$i]->getOptionalProperties(); + foreach ($types[$j]->getProperties() as $propertyName => $propertyType) { + if (array_key_exists($propertyName, $mergedProperties)) { + $intersectedPropertyType = self::intersect($mergedProperties[$propertyName], $propertyType); + if ($intersectedPropertyType instanceof NeverType) { + if ( + in_array($propertyName, $mergedOptionalProperties, true) + && in_array($propertyName, $types[$j]->getOptionalProperties(), true) + ) { + unset($mergedProperties[$propertyName]); + $mergedOptionalProperties = array_values(array_filter( + $mergedOptionalProperties, + static fn ($p) => $p !== $propertyName, + )); + continue; + } + + return new NeverType(); + } + $mergedProperties[$propertyName] = $intersectedPropertyType; + if ( + in_array($propertyName, $mergedOptionalProperties, true) + && !in_array($propertyName, $types[$j]->getOptionalProperties(), true) + ) { + $mergedOptionalProperties = array_values(array_filter( + $mergedOptionalProperties, + static fn ($p) => $p !== $propertyName, + )); + } + } else { + $mergedProperties[$propertyName] = $propertyType; + if (in_array($propertyName, $types[$j]->getOptionalProperties(), true)) { + $mergedOptionalProperties[] = $propertyName; + } + } + } + ksort($mergedProperties); + $types[$i] = new ObjectShapeType($mergedProperties, $mergedOptionalProperties); + array_splice($types, $j--, 1); + $typesCount--; + continue; + } + + if ($types[$i] instanceof ObjectShapeType && $types[$j] instanceof HasPropertyType) { + $propertyName = $types[$j]->getPropertyName(); + if (!array_key_exists($propertyName, $types[$i]->getProperties())) { + $properties = $types[$i]->getProperties(); + $properties[$propertyName] = new MixedType(); + ksort($properties); + $types[$i] = new ObjectShapeType($properties, $types[$i]->getOptionalProperties()); + } else { + $types[$i] = $types[$i]->makePropertyRequired($propertyName); + } + array_splice($types, $j--, 1); + $typesCount--; + continue; + } + + if ($types[$j] instanceof ObjectShapeType && $types[$i] instanceof HasPropertyType) { + $propertyName = $types[$i]->getPropertyName(); + if (!array_key_exists($propertyName, $types[$j]->getProperties())) { + $properties = $types[$j]->getProperties(); + $properties[$propertyName] = new MixedType(); + ksort($properties); + $types[$j] = new ObjectShapeType($properties, $types[$j]->getOptionalProperties()); + } else { + $types[$j] = $types[$j]->makePropertyRequired($propertyName); + } + array_splice($types, $i--, 1); + $typesCount--; + continue 2; + } + if ($types[$j] instanceof IterableType) { $isSuperTypeA = $types[$j]->isSuperTypeOfMixed($types[$i]); } else { @@ -1410,20 +1487,6 @@ public static function intersect(Type ...$types): Type continue 2; } - if ($types[$i] instanceof ObjectShapeType && $types[$j] instanceof HasPropertyType) { - $types[$i] = $types[$i]->makePropertyRequired($types[$j]->getPropertyName()); - array_splice($types, $j--, 1); - $typesCount--; - continue; - } - - if ($types[$j] instanceof ObjectShapeType && $types[$i] instanceof HasPropertyType) { - $types[$j] = $types[$j]->makePropertyRequired($types[$i]->getPropertyName()); - array_splice($types, $i--, 1); - $typesCount--; - continue 2; - } - if ($types[$i] instanceof ConstantArrayType && ($types[$j] instanceof ArrayType || $types[$j] instanceof ConstantArrayType)) { $newArray = ConstantArrayTypeBuilder::createEmpty(); $valueTypes = $types[$i]->getValueTypes(); diff --git a/tests/PHPStan/Analyser/nsrt/bug-13227.php b/tests/PHPStan/Analyser/nsrt/bug-13227.php new file mode 100644 index 0000000000..209bc84d75 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-13227.php @@ -0,0 +1,29 @@ +treatPhpDocTypesAsCertain = true; - $this->analyse([__DIR__ . '/data/property-exists-object-shapes.php'], [ - [ - 'Call to function property_exists() with object{foo: int, bar?: string} and \'baz\' will always evaluate to false.', - 24, - 'Because the type is coming from a PHPDoc, you can turn off this check by setting treatPhpDocTypesAsCertain: false in your %configurationFile%.', - ], - ]); + $this->analyse([__DIR__ . '/data/property-exists-object-shapes.php'], []); } /** @return list */ diff --git a/tests/PHPStan/Type/ObjectShapeTypeTest.php b/tests/PHPStan/Type/ObjectShapeTypeTest.php new file mode 100644 index 0000000000..f66e2a8234 --- /dev/null +++ b/tests/PHPStan/Type/ObjectShapeTypeTest.php @@ -0,0 +1,159 @@ + new IntegerType()], []), + new ObjectShapeType(['foo' => new IntegerType()], []), + TrinaryLogic::createYes(), + ]; + + // Wider property type is supertype + yield [ + new ObjectShapeType(['foo' => new IntegerType()], []), + new ObjectShapeType(['foo' => new ConstantIntegerType(1)], []), + TrinaryLogic::createYes(), + ]; + + // Narrower property type is maybe supertype + yield [ + new ObjectShapeType(['foo' => new ConstantIntegerType(1)], []), + new ObjectShapeType(['foo' => new IntegerType()], []), + TrinaryLogic::createMaybe(), + ]; + + // Incompatible property types + yield [ + new ObjectShapeType(['foo' => new IntegerType()], []), + new ObjectShapeType(['foo' => new StringType()], []), + TrinaryLogic::createNo(), + ]; + + // Disjoint properties - object shapes are open types + yield [ + new ObjectShapeType(['foo' => new IntegerType()], []), + new ObjectShapeType(['bar' => new StringType()], []), + TrinaryLogic::createMaybe(), + ]; + + yield [ + new ObjectShapeType(['bar' => new StringType()], []), + new ObjectShapeType(['foo' => new IntegerType()], []), + TrinaryLogic::createMaybe(), + ]; + + // Required vs optional: optional is supertype of required + yield [ + new ObjectShapeType(['foo' => new IntegerType()], ['foo']), + new ObjectShapeType(['foo' => new IntegerType()], []), + TrinaryLogic::createYes(), + ]; + + // Required vs optional: required is maybe supertype of optional + yield [ + new ObjectShapeType(['foo' => new IntegerType()], []), + new ObjectShapeType(['foo' => new IntegerType()], ['foo']), + TrinaryLogic::createMaybe(), + ]; + + // Wider type with required property + yield [ + new ObjectShapeType(['foo' => TypeCombinator::union(new IntegerType(), new NullType())], []), + new ObjectShapeType(['foo' => new IntegerType()], []), + TrinaryLogic::createYes(), + ]; + + // Narrower type checking wider + yield [ + new ObjectShapeType(['foo' => new IntegerType()], []), + new ObjectShapeType(['foo' => TypeCombinator::union(new IntegerType(), new NullType())], []), + TrinaryLogic::createMaybe(), + ]; + + // Optional wider type vs required narrower + yield [ + new ObjectShapeType(['foo' => TypeCombinator::union(new IntegerType(), new NullType())], ['foo']), + new ObjectShapeType(['foo' => new IntegerType()], []), + TrinaryLogic::createYes(), + ]; + + // Required narrower vs optional wider + yield [ + new ObjectShapeType(['foo' => new IntegerType()], []), + new ObjectShapeType(['foo' => TypeCombinator::union(new IntegerType(), new NullType())], ['foo']), + TrinaryLogic::createMaybe(), + ]; + + // Disjoint with optional property + yield [ + new ObjectShapeType(['foo' => new IntegerType()], []), + new ObjectShapeType(['bar' => new IntegerType()], ['bar']), + TrinaryLogic::createMaybe(), + ]; + + yield [ + new ObjectShapeType(['bar' => new IntegerType()], ['bar']), + new ObjectShapeType(['foo' => new IntegerType()], []), + TrinaryLogic::createMaybe(), + ]; + + // Optional property with incompatible types + yield [ + new ObjectShapeType(['foo' => new IntegerType()], []), + new ObjectShapeType(['foo' => new StringType()], ['foo']), + TrinaryLogic::createMaybe(), + ]; + + // Superset has extra required property - maybe because shapes are open + yield [ + new ObjectShapeType(['foo' => new IntegerType(), 'bar' => new StringType()], []), + new ObjectShapeType(['foo' => new IntegerType()], []), + TrinaryLogic::createMaybe(), + ]; + + // Subset is supertype + yield [ + new ObjectShapeType(['foo' => new IntegerType()], []), + new ObjectShapeType(['foo' => new IntegerType(), 'bar' => new StringType()], []), + TrinaryLogic::createYes(), + ]; + + // Empty shape is supertype of any shape + yield [ + new ObjectShapeType([], []), + new ObjectShapeType(['foo' => new IntegerType()], []), + TrinaryLogic::createYes(), + ]; + + // Any shape is maybe supertype of empty shape + yield [ + new ObjectShapeType(['foo' => new IntegerType()], []), + new ObjectShapeType([], []), + TrinaryLogic::createMaybe(), + ]; + } + + #[DataProvider('dataIsSuperTypeOf')] + public function testIsSuperTypeOf(ObjectShapeType $type, Type $otherType, TrinaryLogic $expectedResult): void + { + $actualResult = $type->isSuperTypeOf($otherType); + $this->assertSame( + $expectedResult->describe(), + $actualResult->describe(), + sprintf('%s -> isSuperTypeOf(%s)', $type->describe(VerbosityLevel::precise()), $otherType->describe(VerbosityLevel::precise())), + ); + } + +} diff --git a/tests/PHPStan/Type/TypeCombinatorTest.php b/tests/PHPStan/Type/TypeCombinatorTest.php index 6c51dbcd9b..7e6557a493 100644 --- a/tests/PHPStan/Type/TypeCombinatorTest.php +++ b/tests/PHPStan/Type/TypeCombinatorTest.php @@ -2578,6 +2578,54 @@ public static function dataUnion(): iterable UnionType::class, 'object{bar: string}|object{foo: int}', ]; + yield [ + [ + new ObjectShapeType(['foo' => new IntegerType()], []), + new ObjectShapeType(['foo' => new IntegerType()], ['foo']), + ], + ObjectShapeType::class, + 'object{foo?: int}', + ]; + yield [ + [ + new ObjectShapeType(['foo' => new IntegerType()], []), + new ObjectShapeType(['foo' => TypeCombinator::union(new IntegerType(), new NullType())], []), + ], + ObjectShapeType::class, + 'object{foo: int|null}', + ]; + yield [ + [ + new ObjectShapeType(['foo' => new IntegerType()], []), + new ObjectShapeType(['foo' => TypeCombinator::union(new IntegerType(), new NullType())], ['foo']), + ], + ObjectShapeType::class, + 'object{foo?: int|null}', + ]; + yield [ + [ + new ObjectShapeType(['foo' => new IntegerType()], []), + new ObjectShapeType(['bar' => new IntegerType()], ['bar']), + ], + UnionType::class, + 'object{bar?: int}|object{foo: int}', + ]; + yield [ + [ + new ObjectShapeType(['foo' => new IntegerType()], []), + new ObjectShapeType(['foo' => new StringType()], ['foo']), + ], + UnionType::class, + 'object{foo: int}|object{foo?: string}', + ]; + yield [ + [ + new ObjectShapeType(['foo' => new IntegerType(), 'bar' => new StringType()], []), + new ObjectShapeType(['foo' => new IntegerType()], []), + ], + ObjectShapeType::class, + 'object{foo: int}', + ]; yield [ [ @@ -4584,8 +4632,8 @@ public static function dataIntersect(): iterable new ObjectShapeType(['foo' => new IntegerType()], []), new HasPropertyType('bar'), ], - NeverType::class, - '*NEVER*=implicit', + ObjectShapeType::class, + 'object{bar: mixed, foo: int}', ]; yield [ [ @@ -4616,9 +4664,73 @@ public static function dataIntersect(): iterable new ObjectShapeType(['foo' => new IntegerType()], []), new ObjectShapeType(['bar' => new StringType()], []), ], + ObjectShapeType::class, + 'object{bar: string, foo: int}', + ]; + yield [ + [ + new ObjectShapeType(['foo' => new IntegerType()], []), + new ObjectShapeType(['foo' => new IntegerType()], ['foo']), + ], + ObjectShapeType::class, + 'object{foo: int}', + ]; + yield [ + [ + new ObjectShapeType(['foo' => new IntegerType()], []), + new ObjectShapeType(['foo' => TypeCombinator::union(new IntegerType(), new NullType())], []), + ], + ObjectShapeType::class, + 'object{foo: int}', + ]; + yield [ + [ + new ObjectShapeType(['foo' => new IntegerType()], []), + new ObjectShapeType(['foo' => TypeCombinator::union(new IntegerType(), new NullType())], ['foo']), + ], + ObjectShapeType::class, + 'object{foo: int}', + ]; + yield [ + [ + new ObjectShapeType(['foo' => new IntegerType()], []), + new ObjectShapeType(['bar' => new IntegerType()], ['bar']), + ], + ObjectShapeType::class, + 'object{bar?: int, foo: int}', + ]; + yield [ + [ + new ObjectShapeType(['foo' => new IntegerType()], []), + new ObjectShapeType(['foo' => new StringType()], ['foo']), + ], NeverType::class, '*NEVER*=implicit', ]; + yield [ + [ + new ObjectShapeType(['foo' => new IntegerType()], ['foo']), + new ObjectShapeType(['foo' => new StringType()], ['foo']), + ], + ObjectShapeType::class, + 'object{}', + ]; + yield [ + [ + new ObjectShapeType(['foo' => new IntegerType()], ['foo']), + new ObjectShapeType(['foo' => new IntegerType()], []), + ], + ObjectShapeType::class, + 'object{foo: int}', + ]; + yield [ + [ + new ObjectShapeType(['foo' => new IntegerType(), 'bar' => new StringType()], ['foo']), + new ObjectShapeType(['foo' => new IntegerType()], []), + ], + ObjectShapeType::class, + 'object{bar: string, foo: int}', + ]; yield [ [ new ObjectShapeType(['foo' => new IntegerType()], []),