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 @@ +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 @@ 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); @@ -685,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()); @@ -708,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); @@ -1206,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); @@ -1235,11 +1238,12 @@ public function walkSelectExpression($selectExpression): string 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 @@ 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 { @@ -2015,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 @@ -2036,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) { @@ -2051,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); @@ -2074,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 2b8a3c14..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; @@ -44,6 +45,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; @@ -187,6 +189,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 +1543,29 @@ private function yieldConditionalDataset(): iterable ]; } + 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 00000000..9d410c5c --- /dev/null +++ b/tests/Type/Doctrine/data/QueryResult/EntitiesDbal42/Dbal4Entity.php @@ -0,0 +1,36 @@ +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(