From 7c3d1a1f78853bb16dba67df538217c92bbd4f79 Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Thu, 24 Apr 2025 16:03:36 +0200 Subject: [PATCH 1/4] Add missing descriptors for SmallFloatType and EnumType --- extension.neon | 6 ++++ phpstan-baseline-dbal-4.neon | 33 +++++++++++++++++++ phpstan-baseline.neon | 6 ++++ phpstan.neon | 1 + src/Type/Doctrine/Descriptors/EnumType.php | 31 +++++++++++++++++ .../Doctrine/Descriptors/SmallFloatType.php | 13 ++++++++ .../Doctrine/Query/QueryResultTypeWalker.php | 2 ++ 7 files changed, 92 insertions(+) create mode 100644 phpstan-baseline-dbal-4.neon create mode 100644 src/Type/Doctrine/Descriptors/EnumType.php create mode 100644 src/Type/Doctrine/Descriptors/SmallFloatType.php diff --git a/extension.neon b/extension.neon index 93102dc0..803ffaed 100644 --- a/extension.neon +++ b/extension.neon @@ -353,6 +353,9 @@ services: - class: PHPStan\Type\Doctrine\Descriptors\DecimalType tags: [phpstan.doctrine.typeDescriptor] + - + class: PHPStan\Type\Doctrine\Descriptors\EnumType + tags: [phpstan.doctrine.typeDescriptor] - class: PHPStan\Type\Doctrine\Descriptors\FloatType tags: [phpstan.doctrine.typeDescriptor] @@ -374,6 +377,9 @@ services: - class: PHPStan\Type\Doctrine\Descriptors\SimpleArrayType tags: [phpstan.doctrine.typeDescriptor] + - + class: PHPStan\Type\Doctrine\Descriptors\SmallFloatType + tags: [phpstan.doctrine.typeDescriptor] - class: PHPStan\Type\Doctrine\Descriptors\SmallIntType tags: [phpstan.doctrine.typeDescriptor] diff --git a/phpstan-baseline-dbal-4.neon b/phpstan-baseline-dbal-4.neon new file mode 100644 index 00000000..24536b91 --- /dev/null +++ b/phpstan-baseline-dbal-4.neon @@ -0,0 +1,33 @@ +parameters: + ignoreErrors: + - + message: '#^Class Doctrine\\DBAL\\Types\\EnumType not found\.$#' + identifier: class.notFound + count: 1 + path: src/Type/Doctrine/Descriptors/EnumType.php + + - + message: '#^Method PHPStan\\Type\\Doctrine\\Descriptors\\EnumType\:\:getType\(\) should return class\-string\ but returns string\.$#' + identifier: return.type + count: 1 + path: src/Type/Doctrine/Descriptors/EnumType.php + + - + message: '#^Class Doctrine\\DBAL\\Types\\SmallFloatType not found\.$#' + identifier: class.notFound + count: 1 + path: src/Type/Doctrine/Descriptors/SmallFloatType.php + + - + message: '#^Method PHPStan\\Type\\Doctrine\\Descriptors\\SmallFloatType\:\:getType\(\) should return class\-string\ but returns string\.$#' + identifier: return.type + count: 1 + path: src/Type/Doctrine/Descriptors/SmallFloatType.php + + - + message: '#^Class Doctrine\\DBAL\\Types\\EnumType not found\.$#' + identifier: class.notFound + count: 1 + path: src/Type/Doctrine/Query/QueryResultTypeWalker.php + + diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index d635d53e..cebcd4e2 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -317,3 +317,9 @@ parameters: identifier: new.deprecatedClass count: 1 path: tests/Type/Doctrine/DBAL/pdo.php + + - + message: '#^Parameter references internal interface Doctrine\\ORM\\Query\\AST\\Phase2OptimizableConditional in its type\.$#' + identifier: parameter.internalInterface + count: 2 + path: src/Type/Doctrine/Query/QueryResultTypeWalker.php diff --git a/phpstan.neon b/phpstan.neon index efbf455d..ae9f2df6 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -4,6 +4,7 @@ includes: - phpstan-baseline.neon - phpstan-baseline-deprecations.neon - phpstan-baseline-dbal-3.neon + - phpstan-baseline-dbal-4.neon - compatibility/orm-3-baseline.php - vendor/phpstan/phpstan-strict-rules/rules.neon - vendor/phpstan/phpstan-deprecation-rules/rules.neon diff --git a/src/Type/Doctrine/Descriptors/EnumType.php b/src/Type/Doctrine/Descriptors/EnumType.php new file mode 100644 index 00000000..cc10d3f8 --- /dev/null +++ b/src/Type/Doctrine/Descriptors/EnumType.php @@ -0,0 +1,31 @@ +getReturnType() instanceof DbalStringType // StringType is no-op, so using TypedExpression with that does nothing + && !$expr->getReturnType() instanceof DbalEnumType // EnumType is also no-op ) { $dbalTypeName = DbalType::getTypeRegistry()->lookupName($expr->getReturnType()); $type = TypeCombinator::intersect( // e.g. count is typed as int, but we infer int<0, max> From a59b33bf3a9a93ce973bce1d82d0f18958e7765f Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Fri, 13 Jun 2025 15:03:16 +0200 Subject: [PATCH 2/4] Test new descriptors --- .../Query/QueryResultTypeWalkerTest.php | 29 +++++++++++++++ .../data/QueryResult/Entities/Dbal4Entity.php | 36 +++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 tests/Type/Doctrine/data/QueryResult/Entities/Dbal4Entity.php diff --git a/tests/Type/Doctrine/Query/QueryResultTypeWalkerTest.php b/tests/Type/Doctrine/Query/QueryResultTypeWalkerTest.php index 2b8a3c14..52e46121 100644 --- a/tests/Type/Doctrine/Query/QueryResultTypeWalkerTest.php +++ b/tests/Type/Doctrine/Query/QueryResultTypeWalkerTest.php @@ -36,6 +36,7 @@ use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; use PHPStan\Type\VerbosityLevel; +use QueryResult\Entities\Dbal4Entity; use QueryResult\Entities\Embedded; use QueryResult\Entities\JoinedChild; use QueryResult\Entities\Many; @@ -187,6 +188,15 @@ public static function setUpBeforeClass(): void $em->persist($entityWithEnum); } + if (InstalledVersions::satisfies(new VersionParser(), 'doctrine/dbal', '>=4.2')) { + assert(class_exists(Dbal4Entity::class)); + + $dbal4Entity = new Dbal4Entity(); + $dbal4Entity->enum = 'a'; + $dbal4Entity->smallfloat = 1.1; + $em->persist($dbal4Entity); + } + $em->flush(); } @@ -1532,6 +1542,25 @@ private function yieldConditionalDataset(): iterable ]; } + if (InstalledVersions::satisfies(new VersionParser(), 'doctrine/dbal', '>=4.2')) { + yield 'enum and smallfloat' => [ + $this->constantArray([ + [ + new ConstantStringType('enum'), + new StringType(), + ], + [ + new ConstantStringType('smallfloat'), + new FloatType(), + ], + ]), + ' + SELECT e.enum, e.smallfloat + FROM QueryResult\Entities\Dbal4Entity e + ', + ]; + } + $ormVersion = InstalledVersions::getVersion('doctrine/orm'); $hasOrm3 = $ormVersion !== null && strpos($ormVersion, '3.') === 0; diff --git a/tests/Type/Doctrine/data/QueryResult/Entities/Dbal4Entity.php b/tests/Type/Doctrine/data/QueryResult/Entities/Dbal4Entity.php new file mode 100644 index 00000000..9fca6927 --- /dev/null +++ b/tests/Type/Doctrine/data/QueryResult/Entities/Dbal4Entity.php @@ -0,0 +1,36 @@ + Date: Fri, 13 Jun 2025 15:15:30 +0200 Subject: [PATCH 3/4] Fix tests for old php and doctrine --- .../Type/Doctrine/Query/QueryResultTypeWalkerTest.php | 2 +- .../{Entities => EntitiesDbal42}/Dbal4Entity.php | 2 +- .../Type/Doctrine/data/QueryResult/entity-manager.php | 10 ++++++++++ 3 files changed, 12 insertions(+), 2 deletions(-) rename tests/Type/Doctrine/data/QueryResult/{Entities => EntitiesDbal42}/Dbal4Entity.php (93%) diff --git a/tests/Type/Doctrine/Query/QueryResultTypeWalkerTest.php b/tests/Type/Doctrine/Query/QueryResultTypeWalkerTest.php index 52e46121..135c248c 100644 --- a/tests/Type/Doctrine/Query/QueryResultTypeWalkerTest.php +++ b/tests/Type/Doctrine/Query/QueryResultTypeWalkerTest.php @@ -36,7 +36,6 @@ use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; use PHPStan\Type\VerbosityLevel; -use QueryResult\Entities\Dbal4Entity; use QueryResult\Entities\Embedded; use QueryResult\Entities\JoinedChild; use QueryResult\Entities\Many; @@ -45,6 +44,7 @@ use QueryResult\Entities\One; use QueryResult\Entities\OneId; use QueryResult\Entities\SingleTableChild; +use QueryResult\EntitiesDbal42\Dbal4Entity; use QueryResult\EntitiesEnum\EntityWithEnum; use QueryResult\EntitiesEnum\IntEnum; use QueryResult\EntitiesEnum\StringEnum; diff --git a/tests/Type/Doctrine/data/QueryResult/Entities/Dbal4Entity.php b/tests/Type/Doctrine/data/QueryResult/EntitiesDbal42/Dbal4Entity.php similarity index 93% rename from tests/Type/Doctrine/data/QueryResult/Entities/Dbal4Entity.php rename to tests/Type/Doctrine/data/QueryResult/EntitiesDbal42/Dbal4Entity.php index 9fca6927..9d410c5c 100644 --- a/tests/Type/Doctrine/data/QueryResult/Entities/Dbal4Entity.php +++ b/tests/Type/Doctrine/data/QueryResult/EntitiesDbal42/Dbal4Entity.php @@ -1,6 +1,6 @@ setProxyDir(__DIR__); @@ -29,6 +32,13 @@ ), 'QueryResult\EntitiesEnum\\'); } +if (InstalledVersions::satisfies(new VersionParser(), 'doctrine/dbal', '>=4.2')) { + $metadataDriver->addDriver(new AnnotationDriver( + new AnnotationReader(), + [__DIR__ . '/EntitiesDbal42'] + ), 'QueryResult\EntitiesDbal42\\'); +} + $config->setMetadataDriverImpl($metadataDriver); return new EntityManager( From 800424ee9b3e80ec0fdf87b15640e790bcdf445f Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Fri, 13 Jun 2025 15:38:54 +0200 Subject: [PATCH 4/4] Properly infer specific enum values, fix test --- .../Doctrine/Query/QueryResultTypeWalker.php | 89 +++++++++++++++---- .../Query/QueryResultTypeWalkerTest.php | 9 +- 2 files changed, 79 insertions(+), 19 deletions(-) diff --git a/src/Type/Doctrine/Query/QueryResultTypeWalker.php b/src/Type/Doctrine/Query/QueryResultTypeWalker.php index 9c05328b..d9233d6e 100644 --- a/src/Type/Doctrine/Query/QueryResultTypeWalker.php +++ b/src/Type/Doctrine/Query/QueryResultTypeWalker.php @@ -27,6 +27,7 @@ use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\Constant\ConstantFloatType; use PHPStan\Type\Constant\ConstantIntegerType; +use PHPStan\Type\Constant\ConstantStringType; use PHPStan\Type\ConstantTypeHelper; use PHPStan\Type\Doctrine\DescriptorNotRegisteredException; use PHPStan\Type\Doctrine\DescriptorRegistry; @@ -54,6 +55,7 @@ use function get_class; use function gettype; use function in_array; +use function is_array; use function is_int; use function is_numeric; use function is_object; @@ -287,13 +289,13 @@ public function walkPathExpression($pathExpr): string switch ($pathExpr->type) { case AST\PathExpression::TYPE_STATE_FIELD: - [$typeName, $enumType] = $this->getTypeOfField($class, $fieldName); + [$typeName, $enumType, $enumValues] = $this->getTypeOfField($class, $fieldName); $nullable = $this->isQueryComponentNullable($dqlAlias) || $class->isNullable($fieldName) || $this->hasAggregateWithoutGroupBy(); - $fieldType = $this->resolveDatabaseInternalType($typeName, $enumType, $nullable); + $fieldType = $this->resolveDatabaseInternalType($typeName, $enumType, $enumValues, $nullable); return $this->marshalType($fieldType); @@ -327,12 +329,12 @@ public function walkPathExpression($pathExpr): string } $targetFieldName = $identifierFieldNames[0]; - [$typeName, $enumType] = $this->getTypeOfField($targetClass, $targetFieldName); + [$typeName, $enumType, $enumValues] = $this->getTypeOfField($targetClass, $targetFieldName); $nullable = ($joinColumn['nullable'] ?? true) || $this->hasAggregateWithoutGroupBy(); - $fieldType = $this->resolveDatabaseInternalType($typeName, $enumType, $nullable); + $fieldType = $this->resolveDatabaseInternalType($typeName, $enumType, $enumValues, $nullable); return $this->marshalType($fieldType); @@ -686,7 +688,7 @@ public function walkFunction($function): string return $this->marshalType(new MixedType()); } - [$typeName, $enumType] = $this->getTypeOfField($targetClass, $targetFieldName); + [$typeName, $enumType, $enumValues] = $this->getTypeOfField($targetClass, $targetFieldName); if (!isset($assoc['joinColumns'])) { return $this->marshalType(new MixedType()); @@ -709,7 +711,7 @@ public function walkFunction($function): string || $this->isQueryComponentNullable($dqlAlias) || $this->hasAggregateWithoutGroupBy(); - $fieldType = $this->resolveDatabaseInternalType($typeName, $enumType, $nullable); + $fieldType = $this->resolveDatabaseInternalType($typeName, $enumType, $enumValues, $nullable); return $this->marshalType($fieldType); @@ -1207,13 +1209,13 @@ public function walkSelectExpression($selectExpression): string assert(array_key_exists('metadata', $qComp)); $class = $qComp['metadata']; - [$typeName, $enumType] = $this->getTypeOfField($class, $fieldName); + [$typeName, $enumType, $enumValues] = $this->getTypeOfField($class, $fieldName); $nullable = $this->isQueryComponentNullable($dqlAlias) || $class->isNullable($fieldName) || $this->hasAggregateWithoutGroupBy(); - $type = $this->resolveDoctrineType($typeName, $enumType, $nullable); + $type = $this->resolveDoctrineType($typeName, $enumType, $enumValues, $nullable); $this->typeBuilder->addScalar($resultAlias, $type); @@ -1241,7 +1243,7 @@ public function walkSelectExpression($selectExpression): string $dbalTypeName = DbalType::getTypeRegistry()->lookupName($expr->getReturnType()); $type = TypeCombinator::intersect( // e.g. count is typed as int, but we infer int<0, max> $type, - $this->resolveDoctrineType($dbalTypeName, null, TypeCombinator::containsNull($type)), + $this->resolveDoctrineType($dbalTypeName, null, null, TypeCombinator::containsNull($type)), ); if ($this->hasAggregateWithoutGroupBy() && !$expr instanceof AST\Functions\CountFunction) { @@ -1999,7 +2001,7 @@ private function isQueryComponentNullable(string $dqlAlias): bool /** * @param ClassMetadata $class - * @return array{string, ?class-string} Doctrine type name and enum type of field + * @return array{string, ?class-string, ?list} Doctrine type name, enum type of field, enum values */ private function getTypeOfField(ClassMetadata $class, string $fieldName): array { @@ -2017,11 +2019,45 @@ private function getTypeOfField(ClassMetadata $class, string $fieldName): array $enumType = null; } - return [$type, $enumType]; + return [$type, $enumType, $this->detectEnumValues($type, $metadata)]; } - /** @param ?class-string $enumType */ - private function resolveDoctrineType(string $typeName, ?string $enumType = null, bool $nullable = false): Type + /** + * @param mixed $metadata + * + * @return list|null + */ + private function detectEnumValues(string $typeName, $metadata): ?array + { + if ($typeName !== 'enum') { + return null; + } + + $values = $metadata['options']['values'] ?? []; + + if (!is_array($values) || count($values) === 0) { + return null; + } + + foreach ($values as $value) { + if (!is_string($value)) { + return null; + } + } + + return array_values($values); + } + + /** + * @param ?class-string $enumType + * @param ?list $enumValues + */ + private function resolveDoctrineType( + string $typeName, + ?string $enumType = null, + ?array $enumValues = null, + bool $nullable = false + ): Type { try { $type = $this->descriptorRegistry @@ -2038,8 +2074,14 @@ private function resolveDoctrineType(string $typeName, ?string $enumType = null, ), ...TypeUtils::getAccessoryTypes($type)); } } + + if ($enumValues !== null) { + $enumValuesType = TypeCombinator::union(...array_map(static fn (string $value) => new ConstantStringType($value), $enumValues)); + $type = TypeCombinator::intersect($enumValuesType, $type); + } + if ($type instanceof NeverType) { - $type = new MixedType(); + $type = new MixedType(); } } catch (DescriptorNotRegisteredException $e) { if ($enumType !== null) { @@ -2053,11 +2095,19 @@ private function resolveDoctrineType(string $typeName, ?string $enumType = null, $type = TypeCombinator::addNull($type); } - return $type; + return $type; } - /** @param ?class-string $enumType */ - private function resolveDatabaseInternalType(string $typeName, ?string $enumType = null, bool $nullable = false): Type + /** + * @param ?class-string $enumType + * @param ?list $enumValues + */ + private function resolveDatabaseInternalType( + string $typeName, + ?string $enumType = null, + ?array $enumValues = null, + bool $nullable = false + ): Type { try { $descriptor = $this->descriptorRegistry->get($typeName); @@ -2076,6 +2126,11 @@ private function resolveDatabaseInternalType(string $typeName, ?string $enumType $type = TypeCombinator::intersect($enumType, $type); } + if ($enumValues !== null) { + $enumValuesType = TypeCombinator::union(...array_map(static fn (string $value) => new ConstantStringType($value), $enumValues)); + $type = TypeCombinator::intersect($enumValuesType, $type); + } + if ($nullable) { $type = TypeCombinator::addNull($type); } diff --git a/tests/Type/Doctrine/Query/QueryResultTypeWalkerTest.php b/tests/Type/Doctrine/Query/QueryResultTypeWalkerTest.php index 135c248c..cd3da8de 100644 --- a/tests/Type/Doctrine/Query/QueryResultTypeWalkerTest.php +++ b/tests/Type/Doctrine/Query/QueryResultTypeWalkerTest.php @@ -35,6 +35,7 @@ use PHPStan\Type\StringType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; +use PHPStan\Type\UnionType; use PHPStan\Type\VerbosityLevel; use QueryResult\Entities\Embedded; use QueryResult\Entities\JoinedChild; @@ -1547,7 +1548,11 @@ private function yieldConditionalDataset(): iterable $this->constantArray([ [ new ConstantStringType('enum'), - new StringType(), + new UnionType([ + new ConstantStringType('a'), + new ConstantStringType('b'), + new ConstantStringType('c'), + ]), ], [ new ConstantStringType('smallfloat'), @@ -1556,7 +1561,7 @@ private function yieldConditionalDataset(): iterable ]), ' SELECT e.enum, e.smallfloat - FROM QueryResult\Entities\Dbal4Entity e + FROM QueryResult\EntitiesDbal42\Dbal4Entity e ', ]; }