diff --git a/compatibility/orm-3-baseline.php b/compatibility/orm-3-baseline.php index 848beaa0..c9e0ea7e 100644 --- a/compatibility/orm-3-baseline.php +++ b/compatibility/orm-3-baseline.php @@ -1,6 +1,7 @@ =3.7'); +if (!$hasExpressionWithReturnType) { + // those fixtures implement an interface that does not exist yet, PHPStan cannot ignore that + $config['parameters']['excludePaths']['analyse'][] = __DIR__ . '/../tests/Platform/ExpressionWithReturnType*.php'; +} + return $config; diff --git a/compatibility/patches/Id.patch b/compatibility/patches/Id.patch index 45097bac..9df7084a 100644 --- a/compatibility/patches/Id.patch +++ b/compatibility/patches/Id.patch @@ -1,11 +1,12 @@ --- src/Mapping/Id.php 2024-02-08 14:18:20 +++ src/Mapping/Id.php 2024-02-08 14:18:23 -@@ -6,6 +6,10 @@ +@@ -6,6 +6,11 @@ use Attribute; +/** + * @Annotation ++ * @NamedArgumentConstructor + * @Target("PROPERTY") + */ #[Attribute(Attribute::TARGET_PROPERTY)] diff --git a/compatibility/patches/JoinColumns.patch b/compatibility/patches/JoinColumns.patch index eb4e6e1b..f7048148 100644 --- a/compatibility/patches/JoinColumns.patch +++ b/compatibility/patches/JoinColumns.patch @@ -1,13 +1,11 @@ --- src/Mapping/JoinColumns.php 2024-02-03 17:50:09 +++ src/Mapping/JoinColumns.php 2024-02-08 14:26:44 -@@ -4,6 +4,10 @@ - +@@ -5,6 +5,8 @@ namespace Doctrine\ORM\Mapping; - -+/** + + /** + * @Annotation + * @Target("PROPERTY") -+ */ - final class JoinColumns implements MappingAttribute - { - /** @param array $value */ + * @deprecated Using this attribute has no effect, use the `#[JoinColumn]` + * attribute instead, it is repeatable. + */ diff --git a/compatibility/patches/OrderBy.patch b/compatibility/patches/OrderBy.patch index 5a8bc1a2..f30ba5ee 100644 --- a/compatibility/patches/OrderBy.patch +++ b/compatibility/patches/OrderBy.patch @@ -1,11 +1,11 @@ --- src/Mapping/OrderBy.php 2024-02-03 17:50:09 +++ src/Mapping/OrderBy.php 2024-02-08 18:01:12 -@@ -5,7 +5,13 @@ - namespace Doctrine\ORM\Mapping; - +@@ -6,7 +6,13 @@ + use Attribute; + use SortDirection; +use Doctrine\Common\Annotations\Annotation\NamedArgumentConstructor; - + +/** + * @Annotation + * @NamedArgumentConstructor() diff --git a/phpstan.neon b/phpstan.neon index 0d60292a..f698aa85 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -21,11 +21,12 @@ parameters: resultCachePath: tmp/resultCache.php excludePaths: - - tests/*/data/* - - tests/*/data-attributes/* - - tests/*/data-php-*/* - - tests/Rules/Doctrine/ORM/entity-manager.php - - tests/Rules/Doctrine/ORM/entity-manager-lazy-ghost-objects.php + analyseAndScan: + - tests/*/data/* + - tests/*/data-attributes/* + - tests/*/data-php-*/* + - tests/Rules/Doctrine/ORM/entity-manager.php + - tests/Rules/Doctrine/ORM/entity-manager-lazy-ghost-objects.php reportUnmatchedIgnoredErrors: false @@ -69,6 +70,22 @@ parameters: - '#^Class Doctrine\\DBAL\\Driver\\PgSQL\\Driver not found\.$#' - '#^Class Doctrine\\DBAL\\Driver\\SQLite3\\Driver not found\.$#' + - + messages: # needed for ORM < 3.7 + - '#^Class Doctrine\\ORM\\Query\\AST\\ExpressionWithReturnType not found\.$#' + - '#^Call to method getReturnTypeName\(\) on an unknown class Doctrine\\ORM\\Query\\AST\\ExpressionWithReturnType\.$#' + path: src/Type/Doctrine/Query/QueryResultTypeWalker.php + + - + messages: # TypedExpression is deprecated since ORM 3.7, but ORM < 3.7 has no replacement + - '#^Instanceof references deprecated interface Doctrine\\ORM\\Query\\AST\\TypedExpression\:#' + - '#^Call to method getReturnType\(\) of deprecated interface Doctrine\\ORM\\Query\\AST\\TypedExpression\:#' + path: src/Type/Doctrine/Query/QueryResultTypeWalker.php + + - + message: '#^Class PHPStan\\Platform\\TypedExpression\w+ implements deprecated interface Doctrine\\ORM\\Query\\AST\\TypedExpression\:#' # tests both interfaces + path: tests/Platform/* + - message: '#^Call to an undefined method Doctrine\\DBAL\\Connection\:\:getWrappedConnection\(\)\.$#' # dropped in DBAL 4 path: src/Type/Doctrine/Query/QueryResultTypeWalker.php diff --git a/src/Type/Doctrine/Query/QueryResultTypeWalker.php b/src/Type/Doctrine/Query/QueryResultTypeWalker.php index 2e5747ff..4ab45ec7 100644 --- a/src/Type/Doctrine/Query/QueryResultTypeWalker.php +++ b/src/Type/Doctrine/Query/QueryResultTypeWalker.php @@ -10,6 +10,7 @@ use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\ORM\Query; use Doctrine\ORM\Query\AST; +use Doctrine\ORM\Query\AST\ExpressionWithReturnType; use Doctrine\ORM\Query\AST\TypedExpression; use Doctrine\ORM\Query\Parser; use Doctrine\ORM\Query\ParserResult; @@ -1235,12 +1236,9 @@ public function walkSelectExpression($selectExpression): string $resultAlias = $selectExpression->fieldIdentificationVariable ?? $this->scalarResultCounter++; $type = $this->unmarshalType($expr->dispatch($this)); - 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()); + $dbalTypeName = $this->resolveExpressionReturnTypeName($expr); + + if ($dbalTypeName !== null) { $type = TypeCombinator::intersect( // e.g. count is typed as int, but we infer int<0, max> $type, $this->resolveDoctrineType($dbalTypeName, null, null, TypeCombinator::containsNull($type)), @@ -2048,6 +2046,32 @@ private function detectEnumValues(string $typeName, $metadata): ?array return array_values($values); } + /** + * Returns null when the expression declares no DBAL type + * or when the declared type does not convert the fetched value. + */ + private function resolveExpressionReturnTypeName(AST\Node $expr): ?string + { + if ($expr instanceof ExpressionWithReturnType) { // ORM 3.7+ + $typeName = $expr->getReturnTypeName(); + } elseif ($expr instanceof TypedExpression) { // deprecated since ORM 3.7 + $typeName = DbalType::getTypeRegistry()->lookupName($expr->getReturnType()); + } else { + return null; + } + + if (DbalType::hasType($typeName)) { + $dbalType = DbalType::getType($typeName); + + // convertToPHPValue() of StringType and EnumType is no-op, so the actual type depends on the driver + if ($dbalType instanceof DbalStringType || $dbalType instanceof DbalEnumType) { + return null; + } + } + + return $typeName; + } + /** * @param ?class-string $enumType * @param ?list $enumValues diff --git a/tests/Platform/ExpressionWithReturnTypeIntegerPiFunction.php b/tests/Platform/ExpressionWithReturnTypeIntegerPiFunction.php new file mode 100644 index 00000000..4e73002b --- /dev/null +++ b/tests/Platform/ExpressionWithReturnTypeIntegerPiFunction.php @@ -0,0 +1,32 @@ +match(TokenType::T_IDENTIFIER); + $parser->match(TokenType::T_OPEN_PARENTHESIS); + $parser->match(TokenType::T_CLOSE_PARENTHESIS); + } + + public function getReturnTypeName(): string + { + return Types::INTEGER; + } + +} diff --git a/tests/Platform/ExpressionWithReturnTypeIntegerWrapFunction.php b/tests/Platform/ExpressionWithReturnTypeIntegerWrapFunction.php new file mode 100644 index 00000000..fe1d8941 --- /dev/null +++ b/tests/Platform/ExpressionWithReturnTypeIntegerWrapFunction.php @@ -0,0 +1,37 @@ +walkArithmeticPrimary($this->expr) . ')'; + } + + public function parse(Parser $parser): void + { + $parser->match(TokenType::T_IDENTIFIER); + $parser->match(TokenType::T_OPEN_PARENTHESIS); + $this->expr = $parser->ArithmeticPrimary(); + $parser->match(TokenType::T_CLOSE_PARENTHESIS); + } + + public function getReturnTypeName(): string + { + return Types::INTEGER; + } + +} diff --git a/tests/Platform/ExpressionWithReturnTypeStringPiFunction.php b/tests/Platform/ExpressionWithReturnTypeStringPiFunction.php new file mode 100644 index 00000000..0c14a229 --- /dev/null +++ b/tests/Platform/ExpressionWithReturnTypeStringPiFunction.php @@ -0,0 +1,32 @@ +match(TokenType::T_IDENTIFIER); + $parser->match(TokenType::T_OPEN_PARENTHESIS); + $parser->match(TokenType::T_CLOSE_PARENTHESIS); + } + + public function getReturnTypeName(): string + { + return Types::STRING; + } + +} diff --git a/tests/Platform/QueryResultTypeWalkerFetchTypeMatrixTest.php b/tests/Platform/QueryResultTypeWalkerFetchTypeMatrixTest.php index aa912a56..7847122a 100644 --- a/tests/Platform/QueryResultTypeWalkerFetchTypeMatrixTest.php +++ b/tests/Platform/QueryResultTypeWalkerFetchTypeMatrixTest.php @@ -17,6 +17,7 @@ use Doctrine\ORM\Mapping\Driver\AnnotationDriver; use Doctrine\ORM\Mapping\Driver\AttributeDriver; use Doctrine\ORM\Query; +use Doctrine\ORM\Query\AST\ExpressionWithReturnType; use Doctrine\ORM\Tools\SchemaTool; use LogicException; use PDO; @@ -57,6 +58,7 @@ use function floor; use function getenv; use function in_array; +use function interface_exists; use function is_string; use function method_exists; use function reset; @@ -3994,6 +3996,72 @@ public static function provideCases(): iterable 'stringify' => self::STRINGIFY_NONE, ]; + if (self::hasExpressionWithReturnType()) { + yield 'RT_INT_PI()' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT RT_INT_PI() FROM %s t', + 'mysqlExpectedType' => self::int(), + 'sqliteExpectedType' => self::int(), + 'pdoPgsqlExpectedType' => self::int(), + 'pgsqlExpectedType' => self::int(), + 'mssqlExpectedType' => self::int(), + 'mysqlExpectedResult' => 3, + 'sqliteExpectedResult' => 3, + 'pdoPgsqlExpectedResult' => 3, + 'pgsqlExpectedResult' => 3, + 'mssqlExpectedResult' => 3, + 'stringify' => self::STRINGIFY_NONE, + ]; + + yield 'RT_STRING_PI()' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT RT_STRING_PI() FROM %s t', + 'mysqlExpectedType' => self::mixed(), + 'sqliteExpectedType' => self::mixed(), + 'pdoPgsqlExpectedType' => self::mixed(), + 'pgsqlExpectedType' => self::mixed(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => '3.14159', + 'sqliteExpectedResult' => 3.14159, + 'pdoPgsqlExpectedResult' => '3.14159', + 'pgsqlExpectedResult' => '3.14159', + 'mssqlExpectedResult' => '3.14159', + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'RT_INT_WRAP(MIN(t.col_float)) + no data' => [ + 'data' => self::dataNone(), + 'dqlTemplate' => 'SELECT RT_INT_WRAP(MIN(t.col_float)) FROM %s t', + 'mysqlExpectedType' => self::intOrNull(), + 'sqliteExpectedType' => self::intOrNull(), + 'pdoPgsqlExpectedType' => self::intOrNull(), + 'pgsqlExpectedType' => self::intOrNull(), + 'mssqlExpectedType' => self::intOrNull(), + 'mysqlExpectedResult' => null, + 'sqliteExpectedResult' => null, + 'pdoPgsqlExpectedResult' => null, + 'pgsqlExpectedResult' => null, + 'mssqlExpectedResult' => null, + 'stringify' => self::STRINGIFY_NONE, + ]; + + yield 'RT_INT_WRAP(MIN(t.col_float))' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT RT_INT_WRAP(MIN(t.col_float)) FROM %s t', + 'mysqlExpectedType' => self::intOrNull(), + 'sqliteExpectedType' => self::intOrNull(), + 'pdoPgsqlExpectedType' => self::intOrNull(), + 'pgsqlExpectedType' => self::intOrNull(), + 'mssqlExpectedType' => self::intOrNull(), + 'mysqlExpectedResult' => 0, + 'sqliteExpectedResult' => 0, + 'pdoPgsqlExpectedResult' => 0, + 'pgsqlExpectedResult' => 0, + 'mssqlExpectedResult' => 0, + 'stringify' => self::STRINGIFY_NONE, + ]; + } + yield 'COALESCE(t.col_datetime, t.col_datetime)' => [ 'data' => self::dataDefault(), 'dqlTemplate' => 'SELECT COALESCE(t.col_datetime, t.col_datetime) FROM %s t', @@ -5051,6 +5119,11 @@ private function getHumanReadablePhpVersion(int $phpVersion): string return floor($phpVersion / 10000) . '.' . floor(($phpVersion % 10000) / 100); } + private static function hasExpressionWithReturnType(): bool + { + return interface_exists(ExpressionWithReturnType::class); // ORM 3.7+ + } + private static function hasDbal4(): bool { if (!class_exists(InstalledVersions::class)) { @@ -5122,6 +5195,12 @@ private function createOrmConfig(): Configuration $config->addCustomStringFunction('STRING_PI', TypedExpressionStringPiFunction::class); $config->addCustomStringFunction('INT_WRAP', TypedExpressionIntegerWrapFunction::class); + if (self::hasExpressionWithReturnType()) { + $config->addCustomStringFunction('RT_INT_PI', ExpressionWithReturnTypeIntegerPiFunction::class); + $config->addCustomStringFunction('RT_STRING_PI', ExpressionWithReturnTypeStringPiFunction::class); + $config->addCustomStringFunction('RT_INT_WRAP', ExpressionWithReturnTypeIntegerWrapFunction::class); + } + return $config; }