Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions compatibility/orm-3-baseline.php
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<?php declare(strict_types = 1);

use Composer\InstalledVersions;
use Composer\Semver\VersionParser;

$includes = [];

Expand All @@ -15,4 +16,10 @@
$config = [];
$config['includes'] = $includes;

$hasExpressionWithReturnType = $ormVersion !== null && InstalledVersions::satisfies(new VersionParser(), 'doctrine/orm', '>=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;
3 changes: 2 additions & 1 deletion compatibility/patches/Id.patch
Original file line number Diff line number Diff line change
@@ -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)]
Expand Down
14 changes: 6 additions & 8 deletions compatibility/patches/JoinColumns.patch
Original file line number Diff line number Diff line change
@@ -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<JoinColumn> $value */
* @deprecated Using this attribute has no effect, use the `#[JoinColumn]`
* attribute instead, it is repeatable.
*/
8 changes: 4 additions & 4 deletions compatibility/patches/OrderBy.patch
Original file line number Diff line number Diff line change
@@ -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()
Expand Down
27 changes: 22 additions & 5 deletions phpstan.neon
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
36 changes: 30 additions & 6 deletions src/Type/Doctrine/Query/QueryResultTypeWalker.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)),
Expand Down Expand Up @@ -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<BackedEnum> $enumType
* @param ?list<string> $enumValues
Expand Down
32 changes: 32 additions & 0 deletions tests/Platform/ExpressionWithReturnTypeIntegerPiFunction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php declare(strict_types = 1);

namespace PHPStan\Platform;

use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Query\AST\ExpressionWithReturnType;
use Doctrine\ORM\Query\AST\Functions\FunctionNode;
use Doctrine\ORM\Query\Parser;
use Doctrine\ORM\Query\SqlWalker;
use Doctrine\ORM\Query\TokenType;

class ExpressionWithReturnTypeIntegerPiFunction extends FunctionNode implements ExpressionWithReturnType
{

public function getSql(SqlWalker $sqlWalker): string
{
return '3.14159';
}

public function parse(Parser $parser): void
{
$parser->match(TokenType::T_IDENTIFIER);
$parser->match(TokenType::T_OPEN_PARENTHESIS);
$parser->match(TokenType::T_CLOSE_PARENTHESIS);
}

public function getReturnTypeName(): string
{
return Types::INTEGER;
}

}
37 changes: 37 additions & 0 deletions tests/Platform/ExpressionWithReturnTypeIntegerWrapFunction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php declare(strict_types = 1);

namespace PHPStan\Platform;

use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Query\AST\ExpressionWithReturnType;
use Doctrine\ORM\Query\AST\Functions\FunctionNode;
use Doctrine\ORM\Query\AST\Node;
use Doctrine\ORM\Query\Parser;
use Doctrine\ORM\Query\SqlWalker;
use Doctrine\ORM\Query\TokenType;

class ExpressionWithReturnTypeIntegerWrapFunction extends FunctionNode implements ExpressionWithReturnType
{

/** @var Node|string */
public $expr;

public function getSql(SqlWalker $sqlWalker): string
{
return '(' . $sqlWalker->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;
}

}
32 changes: 32 additions & 0 deletions tests/Platform/ExpressionWithReturnTypeStringPiFunction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php declare(strict_types = 1);

namespace PHPStan\Platform;

use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Query\AST\ExpressionWithReturnType;
use Doctrine\ORM\Query\AST\Functions\FunctionNode;
use Doctrine\ORM\Query\Parser;
use Doctrine\ORM\Query\SqlWalker;
use Doctrine\ORM\Query\TokenType;

class ExpressionWithReturnTypeStringPiFunction extends FunctionNode implements ExpressionWithReturnType
{

public function getSql(SqlWalker $sqlWalker): string
{
return '3.14159';
}

public function parse(Parser $parser): void
{
$parser->match(TokenType::T_IDENTIFIER);
$parser->match(TokenType::T_OPEN_PARENTHESIS);
$parser->match(TokenType::T_CLOSE_PARENTHESIS);
}

public function getReturnTypeName(): string
{
return Types::STRING;
}

}
79 changes: 79 additions & 0 deletions tests/Platform/QueryResultTypeWalkerFetchTypeMatrixTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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;
}

Expand Down
Loading