Repository: phpstan/phpstan-doctrine (php)

Issue:
Doctrine DBAL 4.2 ships an Types::ENUM which should be preferred over implementing custom types for ENUM fields.
New developments concerning enum types are out now:

Doctrine DBAL 4.2 ships an Types::ENUM which should be preferred over implementing custom types for ENUM fields.

https://github.com/doctrine/orm/pull/11666
https://github.com/doctrine/orm/issues/11668

The following ways to use enums are yielding `Property App\Entity\Project::$status: Doctrine type "enum" does not have any registered descriptor.`

```
    #[ORM\Column(type: Types::ENUM)]
    private ?ProjectStatus $status = ProjectStatus::Open;
```

```
    #[ORM\Column(type: Types::ENUM, options: ['values' => ['open', 'locked', 'confirmed']])]
    private ?string $status = 'open';
```

Pull request description:
Add missing descriptors for `SmallFloatType` and `EnumType`
- https://github.com/doctrine/dbal/pull/6536 (since dbal 4.2)
- https://github.com/doctrine/dbal/pull/6471 (since dbal 4.1)
- Resolves #619

Patch:
diff --git a/extension.neon b/extension.neon
index 93102dc..803ffae 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 0000000..24536b9
--- /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\<Doctrine\\DBAL\\Types\\Type\> 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\<Doctrine\\DBAL\\Types\\Type\> 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 d635d53..cebcd4e 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 efbf455..ae9f2df 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 0000000..cc10d3f
--- /dev/null
+++ b/src/Type/Doctrine/Descriptors/EnumType.php
@@ -0,0 +1,31 @@
+<?php declare(strict_types = 1);
+
+namespace PHPStan\Type\Doctrine\Descriptors;
+
+use PHPStan\Type\StringType;
+use PHPStan\Type\Type;
+
+class EnumType implements DoctrineTypeDescriptor
+{
+
+	public function getType(): string
+	{
+		return \Doctrine\DBAL\Types\EnumType::class;
+	}
+
+	public function getWritableToPropertyType(): Type
+	{
+		return new StringType();
+	}
+
+	public function getWritableToDatabaseType(): Type
+	{
+		return new StringType();
+	}
+
+	public function getDatabaseInternalType(): Type
+	{
+		return new StringType();
+	}
+
+}
diff --git a/src/Type/Doctrine/Descriptors/SmallFloatType.php b/src/Type/Doctrine/Descriptors/SmallFloatType.php
new file mode 100644
index 0000000..6e107a7
--- /dev/null
+++ b/src/Type/Doctrine/Descriptors/SmallFloatType.php
@@ -0,0 +1,13 @@
+<?php declare(strict_types = 1);
+
+namespace PHPStan\Type\Doctrine\Descriptors;
+
+class SmallFloatType extends FloatType
+{
+
+	public function getType(): string
+	{
+		return \Doctrine\DBAL\Types\SmallFloatType::class;
+	}
+
+}
diff --git a/src/Type/Doctrine/Query/QueryResultTypeWalker.php b/src/Type/Doctrine/Query/QueryResultTypeWalker.php
index 128ae67..d9233d6 100644
--- a/src/Type/Doctrine/Query/QueryResultTypeWalker.php
+++ b/src/Type/Doctrine/Query/QueryResultTypeWalker.php
@@ -3,6 +3,7 @@
 namespace PHPStan\Type\Doctrine\Query;
 
 use BackedEnum;
+use Doctrine\DBAL\Types\EnumType as DbalEnumType;
 use Doctrine\DBAL\Types\StringType as DbalStringType;
 use Doctrine\DBAL\Types\Type as DbalType;
 use Doctrine\ORM\EntityManagerInterface;
@@ -26,6 +27,7 @@ use PHPStan\Type\BooleanType;
 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;
@@ -53,6 +55,7 @@ use function count;
 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;
@@ -286,13 +289,13 @@ class QueryResultTypeWalker extends SqlWalker
 
 		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);
 
@@ -326,12 +329,12 @@ class QueryResultTypeWalker extends SqlWalker
 				}
 
 				$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);
 
@@ -685,7 +688,7 @@ class QueryResultTypeWalker extends SqlWalker
 					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());
@@ -708,7 +711,7 @@ class QueryResultTypeWalker extends SqlWalker
 					|| $this->isQueryComponentNullable($dqlAlias)
 					|| $this->hasAggregateWithoutGroupBy();
 
-				$fieldType = $this->resolveDatabaseInternalType($typeName, $enumType, $nullable);
+				$fieldType = $this->resolveDatabaseInternalType($typeName, $enumType, $enumValues, $nullable);
 
 				return $this->marshalType($fieldType);
 
@@ -1206,13 +1209,13 @@ class QueryResultTypeWalker extends SqlWalker
 			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);
 
@@ -1235,11 +1238,12 @@ class QueryResultTypeWalker extends SqlWalker
 			if (
 				$expr instanceof TypedExpression
 				&& !$expr->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>
 					$type,
-					$this->resolveDoctrineType($dbalTypeName, null, TypeCombinator::containsNull($type)),
+					$this->resolveDoctrineType($dbalTypeName, null, null, TypeCombinator::containsNull($type)),
 				);
 
 				if ($this->hasAggregateWithoutGroupBy() && !$expr instanceof AST\Functions\CountFunction) {
@@ -1997,7 +2001,7 @@ class QueryResultTypeWalker extends SqlWalker
 
 	/**
 	 * @param ClassMetadata<object> $class
-	 * @return array{string, ?class-string<BackedEnum>} Doctrine type name and enum type of field
+	 * @return array{string, ?class-string<BackedEnum>, ?list<string>} Doctrine type name, enum type of field, enum values
 	 */
 	private function getTypeOfField(ClassMetadata $class, string $fieldName): array
 	{
@@ -2015,11 +2019,45 @@ class QueryResultTypeWalker extends SqlWalker
 			$enumType = null;
 		}
 
-		return [$type, $enumType];
+		return [$type, $enumType, $this->detectEnumValues($type, $metadata)];
 	}
 
-	/** @param ?class-string<BackedEnum> $enumType */
-	private function resolveDoctrineType(string $typeName, ?string $enumType = null, bool $nullable = false): Type
+	/**
+	 * @param mixed $metadata
+	 *
+	 * @return list<string>|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<BackedEnum> $enumType
+	 * @param ?list<string> $enumValues
+	 */
+	private function resolveDoctrineType(
+		string $typeName,
+		?string $enumType = null,
+		?array $enumValues = null,
+		bool $nullable = false
+	): Type
 	{
 		try {
 			$type = $this->descriptorRegistry
@@ -2036,8 +2074,14 @@ class QueryResultTypeWalker extends SqlWalker
 					), ...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) {
@@ -2051,11 +2095,19 @@ class QueryResultTypeWalker extends SqlWalker
 			$type = TypeCombinator::addNull($type);
 		}
 
-			return $type;
+		return $type;
 	}
 
-	/** @param ?class-string<BackedEnum> $enumType */
-	private function resolveDatabaseInternalType(string $typeName, ?string $enumType = null, bool $nullable = false): Type
+	/**
+	 * @param ?class-string<BackedEnum> $enumType
+	 * @param ?list<string> $enumValues
+	 */
+	private function resolveDatabaseInternalType(
+		string $typeName,
+		?string $enumType = null,
+		?array $enumValues = null,
+		bool $nullable = false
+	): Type
 	{
 		try {
 			$descriptor = $this->descriptorRegistry->get($typeName);
@@ -2074,6 +2126,11 @@ class QueryResultTypeWalker extends SqlWalker
 			$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);
 		}


Test patch:
diff --git a/tests/Type/Doctrine/Query/QueryResultTypeWalkerTest.php b/tests/Type/Doctrine/Query/QueryResultTypeWalkerTest.php
index 2b8a3c1..cd3da8d 100644
--- a/tests/Type/Doctrine/Query/QueryResultTypeWalkerTest.php
+++ b/tests/Type/Doctrine/Query/QueryResultTypeWalkerTest.php
@@ -35,6 +35,7 @@ use PHPStan\Type\ObjectType;
 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;
@@ -44,6 +45,7 @@ use QueryResult\Entities\NestedEmbedded;
 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;
@@ -187,6 +189,15 @@ final class QueryResultTypeWalkerTest extends PHPStanTestCase
 			$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 +1543,29 @@ final class QueryResultTypeWalkerTest extends PHPStanTestCase
 			];
 		}
 
+		if (InstalledVersions::satisfies(new VersionParser(), 'doctrine/dbal', '>=4.2')) {
+			yield 'enum and smallfloat' => [
+				$this->constantArray([
+					[
+						new ConstantStringType('enum'),
+						new UnionType([
+							new ConstantStringType('a'),
+							new ConstantStringType('b'),
+							new ConstantStringType('c'),
+						]),
+					],
+					[
+						new ConstantStringType('smallfloat'),
+						new FloatType(),
+					],
+				]),
+				'
+					SELECT		e.enum, e.smallfloat
+					FROM		QueryResult\EntitiesDbal42\Dbal4Entity e
+				',
+			];
+		}
+
 		$ormVersion = InstalledVersions::getVersion('doctrine/orm');
 		$hasOrm3 = $ormVersion !== null && strpos($ormVersion, '3.') === 0;
 
diff --git a/tests/Type/Doctrine/data/QueryResult/EntitiesDbal42/Dbal4Entity.php b/tests/Type/Doctrine/data/QueryResult/EntitiesDbal42/Dbal4Entity.php
new file mode 100644
index 0000000..9d410c5
--- /dev/null
+++ b/tests/Type/Doctrine/data/QueryResult/EntitiesDbal42/Dbal4Entity.php
@@ -0,0 +1,36 @@
+<?php declare(strict_types=1);
+
+namespace QueryResult\EntitiesDbal42;
+
+use Doctrine\ORM\Mapping\Column;
+use Doctrine\ORM\Mapping\Entity;
+use Doctrine\ORM\Mapping\GeneratedValue;
+use Doctrine\ORM\Mapping\Id;
+
+/**
+ * @Entity
+ */
+class Dbal4Entity
+{
+
+	/**
+	 * @Column(type="integer")
+	 * @GeneratedValue(strategy="AUTO")
+	 * @Id()
+	 *
+	 * @var int
+	 */
+	public $id;
+
+	/**
+	 * @Column(type="enum", options={"values"={"a", "b", "c"}})
+	 * @var string
+	 */
+	public $enum;  // dbal 4.2+
+
+	/**
+	 * @Column(type="smallfloat")
+	 * @var float
+	 */
+	public $smallfloat; // dbal 4.1+
+}
diff --git a/tests/Type/Doctrine/data/QueryResult/entity-manager.php b/tests/Type/Doctrine/data/QueryResult/entity-manager.php
index c9ba38a..da6fd9f 100644
--- a/tests/Type/Doctrine/data/QueryResult/entity-manager.php
+++ b/tests/Type/Doctrine/data/QueryResult/entity-manager.php
@@ -1,6 +1,8 @@
 <?php declare(strict_types = 1);
 
 use Cache\Adapter\PHPArray\ArrayCachePool;
+use Composer\InstalledVersions;
+use Composer\Semver\VersionParser;
 use Doctrine\Common\Annotations\AnnotationReader;
 use Doctrine\DBAL\DriverManager;
 use Doctrine\ORM\Configuration;
@@ -9,6 +11,7 @@ use Doctrine\ORM\Mapping\Column;
 use Doctrine\ORM\Mapping\Driver\AnnotationDriver;
 use Doctrine\ORM\Mapping\Driver\AttributeDriver;
 use Doctrine\Persistence\Mapping\Driver\MappingDriverChain;
+use QueryResult\EntitiesDbal42\Dbal4Entity;
 
 $config = new Configuration();
 $config->setProxyDir(__DIR__);
@@ -29,6 +32,13 @@ if (property_exists(Column::class, 'enumType') && PHP_VERSION_ID >= 80100) {
 	), '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(