diff --git a/src/Type/Doctrine/Query/QueryResultTypeWalker.php b/src/Type/Doctrine/Query/QueryResultTypeWalker.php index e5040fb8..5157a631 100644 --- a/src/Type/Doctrine/Query/QueryResultTypeWalker.php +++ b/src/Type/Doctrine/Query/QueryResultTypeWalker.php @@ -978,6 +978,7 @@ public function walkJoin($join): string */ public function walkCoalesceExpression($coalesceExpression): string { + $rawTypes = []; $expressionTypes = []; $allTypesContainNull = true; @@ -987,22 +988,67 @@ public function walkCoalesceExpression($coalesceExpression): string continue; } - $type = $this->unmarshalType($expression->dispatch($this)); - $allTypesContainNull = $allTypesContainNull && $this->canBeNull($type); + $rawType = $this->unmarshalType($expression->dispatch($this)); + $rawTypes[] = $rawType; + + $allTypesContainNull = $allTypesContainNull && $this->canBeNull($rawType); // Some drivers manipulate the types, lets avoid false positives by generalizing constant types // e.g. sqlsrv: "COALESCE returns the data type of value with the highest precedence" // e.g. mysql: COALESCE(1, 'foo') === '1' (undocumented? https://gist.github.com/jrunning/4535434) - $expressionTypes[] = $this->generalizeConstantType($type, false); + $expressionTypes[] = $this->generalizeConstantType($rawType, false); } - $type = TypeCombinator::union(...$expressionTypes); + $generalizedUnion = TypeCombinator::union(...$expressionTypes); if (!$allTypesContainNull) { - $type = TypeCombinator::removeNull($type); + $generalizedUnion = TypeCombinator::removeNull($generalizedUnion); } - return $this->marshalType($type); + if ($this->driverType === DriverDetector::MYSQLI || $this->driverType === DriverDetector::PDO_MYSQL) { + return $this->marshalType( + $this->inferCoalesceForMySql($rawTypes, $generalizedUnion) + ); + } + + return $this->marshalType($generalizedUnion); + } + + /** + * @param list $rawTypes + */ + private function inferCoalesceForMySql(array $rawTypes, Type $originalResult): Type + { + $containsString = false; + $containsFloat = false; + $allIsNumericExcludingLiteralString = true; + + foreach ($rawTypes as $rawType) { + $rawTypeNoNull = TypeCombinator::removeNull($rawType); + $isLiteralString = $rawTypeNoNull instanceof DqlConstantStringType && $rawTypeNoNull->getOriginLiteralType() === AST\Literal::STRING; + + if (!$this->containsOnlyNumericTypes($rawTypeNoNull) || $isLiteralString) { + $allIsNumericExcludingLiteralString = false; + } + + if ($rawTypeNoNull->isString()->yes()) { + $containsString = true; + } + + if (!$rawTypeNoNull->isFloat()->yes()) { + continue; + } + + $containsFloat = true; + } + + if ($containsFloat && $allIsNumericExcludingLiteralString) { + return $this->simpleFloatify($originalResult); + } elseif ($containsString) { + return $this->simpleStringify($originalResult); + } + + return $originalResult; } /** @@ -2107,4 +2153,34 @@ private function isSupportedDriver(): bool ], true); } + private function simpleStringify(Type $type): Type + { + return TypeTraverser::map($type, static function (Type $type, callable $traverse): Type { + if ($type instanceof UnionType || $type instanceof IntersectionType) { + return $traverse($type); + } + + if ($type instanceof IntegerType || $type instanceof FloatType || $type instanceof BooleanType) { + return $type->toString(); + } + + return $traverse($type); + }); + } + + private function simpleFloatify(Type $type): Type + { + return TypeTraverser::map($type, static function (Type $type, callable $traverse): Type { + if ($type instanceof UnionType || $type instanceof IntersectionType) { + return $traverse($type); + } + + if ($type instanceof IntegerType || $type instanceof BooleanType || $type instanceof StringType) { + return $type->toFloat(); + } + + return $traverse($type); + }); + } + } diff --git a/tests/Platform/QueryResultTypeWalkerFetchTypeMatrixTest.php b/tests/Platform/QueryResultTypeWalkerFetchTypeMatrixTest.php index 2623181e..dcf9221f 100644 --- a/tests/Platform/QueryResultTypeWalkerFetchTypeMatrixTest.php +++ b/tests/Platform/QueryResultTypeWalkerFetchTypeMatrixTest.php @@ -3980,7 +3980,7 @@ public static function provideCases(): iterable yield 'COALESCE(SUM(t.col_int_nullable), 0)' => [ 'data' => self::dataDefault(), 'select' => 'SELECT COALESCE(SUM(t.col_int_nullable), 0) FROM %s t', - 'mysql' => TypeCombinator::union(self::numericString(), self::int()), + 'mysql' => self::numericString(), 'sqlite' => self::int(), 'pdo_pgsql' => TypeCombinator::union(self::numericString(), self::int()), 'pgsql' => TypeCombinator::union(self::numericString(), self::int()), @@ -3996,7 +3996,7 @@ public static function provideCases(): iterable yield 'COALESCE(SUM(ABS(t.col_int)), 0)' => [ 'data' => self::dataDefault(), 'select' => 'SELECT COALESCE(SUM(ABS(t.col_int)), 0) FROM %s t', - 'mysql' => TypeCombinator::union(self::int(), self::numericString()), + 'mysql' => self::numericString(), 'sqlite' => self::int(), 'pdo_pgsql' => TypeCombinator::union(self::int(), self::numericString()), 'pgsql' => TypeCombinator::union(self::int(), self::numericString()), @@ -4012,7 +4012,7 @@ public static function provideCases(): iterable yield "COALESCE(t.col_int_nullable, 'foo')" => [ 'data' => self::dataDefault(), 'select' => "SELECT COALESCE(t.col_int_nullable, 'foo') FROM %s t", - 'mysql' => TypeCombinator::union(self::int(), self::string()), + 'mysql' => self::string(), 'sqlite' => TypeCombinator::union(self::int(), self::string()), 'pdo_pgsql' => null, // COALESCE types cannot be matched 'pgsql' => null, // COALESCE types cannot be matched @@ -4028,7 +4028,7 @@ public static function provideCases(): iterable yield "COALESCE(t.col_int, 'foo')" => [ 'data' => self::dataDefault(), 'select' => "SELECT COALESCE(t.col_int, 'foo') FROM %s t", - 'mysql' => TypeCombinator::union(self::int(), self::string()), + 'mysql' => self::string(), 'sqlite' => TypeCombinator::union(self::int(), self::string()), 'pdo_pgsql' => null, // COALESCE types cannot be matched 'pgsql' => null, // COALESCE types cannot be matched @@ -4044,7 +4044,7 @@ public static function provideCases(): iterable yield "COALESCE(t.col_bool, 'foo')" => [ 'data' => self::dataDefault(), 'select' => "SELECT COALESCE(t.col_bool, 'foo') FROM %s t", - 'mysql' => TypeCombinator::union(self::int(), self::string()), + 'mysql' => self::string(), 'sqlite' => TypeCombinator::union(self::int(), self::string()), 'pdo_pgsql' => null, // COALESCE types cannot be matched 'pgsql' => null, // COALESCE types cannot be matched @@ -4060,7 +4060,7 @@ public static function provideCases(): iterable yield "COALESCE(1, 'foo')" => [ 'data' => self::dataDefault(), 'select' => "SELECT COALESCE(1, 'foo') FROM %s t", - 'mysql' => TypeCombinator::union(self::int(), self::string()), + 'mysql' => self::string(), 'sqlite' => TypeCombinator::union(self::int(), self::string()), 'pdo_pgsql' => null, // COALESCE types cannot be matched 'pgsql' => null, // COALESCE types cannot be matched @@ -4073,6 +4073,86 @@ public static function provideCases(): iterable 'stringify' => self::STRINGIFY_DEFAULT, ]; + yield "COALESCE(1, '1')" => [ + 'data' => self::dataDefault(), + 'select' => "SELECT COALESCE(1, '1') FROM %s t", + 'mysql' => self::numericString(), + 'sqlite' => TypeCombinator::union(self::int(), self::numericString()), + 'pdo_pgsql' => TypeCombinator::union(self::int(), self::numericString()), + 'pgsql' => TypeCombinator::union(self::int(), self::numericString()), + 'mssql' => self::mixed(), + 'mysqlResult' => '1', + 'sqliteResult' => 1, + 'pdoPgsqlResult' => 1, + 'pgsqlResult' => 1, + 'mssqlResult' => 1, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'COALESCE(1, 1.0)' => [ + 'data' => self::dataDefault(), + 'select' => 'SELECT COALESCE(1, 1.0) FROM %s t', + 'mysql' => self::numericString(), + 'sqlite' => TypeCombinator::union(self::int(), self::float()), + 'pdo_pgsql' => TypeCombinator::union(self::int(), self::numericString()), + 'pgsql' => TypeCombinator::union(self::int(), self::numericString()), + 'mssql' => self::mixed(), + 'mysqlResult' => '1.0', + 'sqliteResult' => 1, + 'pdoPgsqlResult' => '1', + 'pgsqlResult' => '1', + 'mssqlResult' => '1.0', + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'COALESCE(1e0, 1.0)' => [ + 'data' => self::dataDefault(), + 'select' => 'SELECT COALESCE(1e0, 1.0) FROM %s t', + 'mysql' => self::float(), + 'sqlite' => self::float(), + 'pdo_pgsql' => self::numericString(), + 'pgsql' => self::numericString(), + 'mssql' => self::mixed(), + 'mysqlResult' => 1.0, + 'sqliteResult' => 1.0, + 'pdoPgsqlResult' => '1', + 'pgsqlResult' => '1', + 'mssqlResult' => 1.0, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'COALESCE(1, 1.0, 1e0)' => [ + 'data' => self::dataDefault(), + 'select' => 'SELECT COALESCE(1, 1.0, 1e0) FROM %s t', + 'mysql' => self::float(), + 'sqlite' => TypeCombinator::union(self::float(), self::int()), + 'pdo_pgsql' => TypeCombinator::union(self::int(), self::numericString()), + 'pgsql' => TypeCombinator::union(self::int(), self::numericString()), + 'mssql' => self::mixed(), + 'mysqlResult' => 1.0, + 'sqliteResult' => 1, + 'pdoPgsqlResult' => '1', + 'pgsqlResult' => '1', + 'mssqlResult' => 1.0, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield "COALESCE(1, 1.0, 1e0, '1')" => [ + 'data' => self::dataDefault(), + 'select' => "SELECT COALESCE(1, 1.0, 1e0, '1') FROM %s t", + 'mysql' => self::numericString(), + 'sqlite' => TypeCombinator::union(self::float(), self::int(), self::numericString()), + 'pdo_pgsql' => TypeCombinator::union(self::int(), self::numericString()), + 'pgsql' => TypeCombinator::union(self::int(), self::numericString()), + 'mssql' => self::mixed(), + 'mysqlResult' => '1', + 'sqliteResult' => 1, + 'pdoPgsqlResult' => '1', + 'pgsqlResult' => '1', + 'mssqlResult' => 1.0, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + yield 'COALESCE(t.col_int_nullable, 0)' => [ 'data' => self::dataDefault(), 'select' => 'SELECT COALESCE(t.col_int_nullable, 0) FROM %s t', @@ -4089,10 +4169,26 @@ public static function provideCases(): iterable 'stringify' => self::STRINGIFY_DEFAULT, ]; + yield 'COALESCE(t.col_int_nullable, t.col_bool)' => [ + 'data' => self::dataDefault(), + 'select' => 'SELECT COALESCE(t.col_int_nullable, t.col_bool) FROM %s t', + 'mysql' => self::int(), + 'sqlite' => self::int(), + 'pdo_pgsql' => null, // COALESCE types cannot be matched + 'pgsql' => null, // COALESCE types cannot be matched + 'mssql' => self::mixed(), + 'mysqlResult' => 1, + 'sqliteResult' => 1, + 'pdoPgsqlResult' => null, + 'pgsqlResult' => null, + 'mssqlResult' => 1, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + yield 'COALESCE(t.col_float_nullable, 0)' => [ 'data' => self::dataDefault(), 'select' => 'SELECT COALESCE(t.col_float_nullable, 0) FROM %s t', - 'mysql' => TypeCombinator::union(self::float(), self::int()), + 'mysql' => self::float(), 'sqlite' => TypeCombinator::union(self::float(), self::int()), 'pdo_pgsql' => TypeCombinator::union(self::numericString(), self::int()), 'pgsql' => TypeCombinator::union(self::float(), self::int()), @@ -4108,7 +4204,7 @@ public static function provideCases(): iterable yield 'COALESCE(t.col_float_nullable, 0.0)' => [ 'data' => self::dataDefault(), 'select' => 'SELECT COALESCE(t.col_float_nullable, 0.0) FROM %s t', - 'mysql' => TypeCombinator::union(self::float(), self::numericString()), + 'mysql' => self::float(), 'sqlite' => self::float(), 'pdo_pgsql' => self::numericString(), 'pgsql' => TypeCombinator::union(self::float(), self::numericString()), @@ -4124,7 +4220,7 @@ public static function provideCases(): iterable yield 'COALESCE(t.col_int_nullable, t.col_decimal_nullable, 0)' => [ 'data' => self::dataDefault(), 'select' => 'SELECT COALESCE(t.col_int_nullable, t.col_decimal_nullable, 0) FROM %s t', - 'mysql' => TypeCombinator::union(self::numericString(), self::int()), + 'mysql' => self::numericString(), 'sqlite' => TypeCombinator::union(self::float(), self::int()), 'pdo_pgsql' => TypeCombinator::union(self::numericString(), self::int()), 'pgsql' => TypeCombinator::union(self::numericString(), self::int()), @@ -4140,7 +4236,7 @@ public static function provideCases(): iterable yield 'COALESCE(t.col_int_nullable, t.col_decimal_nullable, t.col_float_nullable, 0)' => [ 'data' => self::dataDefault(), 'select' => 'SELECT COALESCE(t.col_int_nullable, t.col_decimal_nullable, t.col_float_nullable, 0) FROM %s t', - 'mysql' => TypeCombinator::union(self::numericString(), self::int(), self::float()), + 'mysql' => self::float(), 'sqlite' => TypeCombinator::union(self::float(), self::int()), 'pdo_pgsql' => TypeCombinator::union(self::numericString(), self::int()), 'pgsql' => TypeCombinator::union(self::numericString(), self::int(), self::float()), @@ -4153,10 +4249,58 @@ public static function provideCases(): iterable 'stringify' => self::STRINGIFY_DEFAULT, ]; + yield 'COALESCE(t.col_int_nullable, t.col_decimal_nullable, t.col_float_nullable, 0.0)' => [ + 'data' => self::dataDefault(), + 'select' => 'SELECT COALESCE(t.col_int_nullable, t.col_decimal_nullable, t.col_float_nullable, 0.0) FROM %s t', + 'mysql' => self::float(), + 'sqlite' => TypeCombinator::union(self::float(), self::int()), + 'pdo_pgsql' => TypeCombinator::union(self::numericString(), self::int()), + 'pgsql' => TypeCombinator::union(self::numericString(), self::int(), self::float()), + 'mssql' => self::mixed(), + 'mysqlResult' => 0.0, + 'sqliteResult' => 0.0, + 'pdoPgsqlResult' => '0', + 'pgsqlResult' => 0.0, + 'mssqlResult' => 0.0, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'COALESCE(t.col_int_nullable, t.col_decimal_nullable, t.col_float_nullable, 0e0)' => [ + 'data' => self::dataDefault(), + 'select' => 'SELECT COALESCE(t.col_int_nullable, t.col_decimal_nullable, t.col_float_nullable, 0e0) FROM %s t', + 'mysql' => self::float(), + 'sqlite' => TypeCombinator::union(self::float(), self::int()), + 'pdo_pgsql' => TypeCombinator::union(self::numericString(), self::int()), + 'pgsql' => TypeCombinator::union(self::numericString(), self::int(), self::float()), + 'mssql' => self::mixed(), + 'mysqlResult' => 0.0, + 'sqliteResult' => 0.0, + 'pdoPgsqlResult' => '0', + 'pgsqlResult' => 0.0, + 'mssqlResult' => 0.0, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield "COALESCE(t.col_int_nullable, t.col_decimal_nullable, t.col_float_nullable, '0')" => [ + 'data' => self::dataDefault(), + 'select' => 'SELECT COALESCE(t.col_int_nullable, t.col_decimal_nullable, t.col_float_nullable, \'0\') FROM %s t', + 'mysql' => self::numericString(), + 'sqlite' => TypeCombinator::union(self::float(), self::int(), self::numericString()), + 'pdo_pgsql' => TypeCombinator::union(self::numericString(), self::int()), + 'pgsql' => TypeCombinator::union(self::numericString(), self::int(), self::float()), + 'mssql' => self::mixed(), + 'mysqlResult' => '0', + 'sqliteResult' => '0', + 'pdoPgsqlResult' => '0', + 'pgsqlResult' => 0.0, + 'mssqlResult' => 0.0, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + yield 'COALESCE(t.col_int_nullable, t.col_decimal_nullable, t.col_float_nullable, t.col_string)' => [ 'data' => self::dataDefault(), 'select' => 'SELECT COALESCE(t.col_int_nullable, t.col_decimal_nullable, t.col_float_nullable, t.col_string) FROM %s t', - 'mysql' => TypeCombinator::union(self::string(), self::int(), self::float()), + 'mysql' => self::string(), 'sqlite' => TypeCombinator::union(self::float(), self::int(), self::string()), 'pdo_pgsql' => null, // COALESCE types cannot be matched 'pgsql' => null, // COALESCE types cannot be matched @@ -4169,6 +4313,38 @@ public static function provideCases(): iterable 'stringify' => self::STRINGIFY_DEFAULT, ]; + yield 'COALESCE(t.col_int_nullable, t.col_decimal_nullable, t.col_float_nullable, t.col_mixed)' => [ + 'data' => self::dataDefault(), + 'select' => 'SELECT COALESCE(t.col_int_nullable, t.col_decimal_nullable, t.col_float_nullable, t.col_mixed) FROM %s t', + 'mysql' => self::mixed(), + 'sqlite' => self::mixed(), + 'pdo_pgsql' => self::mixed(), + 'pgsql' => self::mixed(), + 'mssql' => self::mixed(), + 'mysqlResult' => 1.0, + 'sqliteResult' => 1, + 'pdoPgsqlResult' => '1', + 'pgsqlResult' => 1.0, + 'mssqlResult' => 1.0, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'COALESCE(t.col_string_nullable, t.col_int)' => [ + 'data' => self::dataDefault(), + 'select' => 'SELECT COALESCE(t.col_string_nullable, t.col_int) FROM %s t', + 'mysql' => self::string(), + 'sqlite' => TypeCombinator::union(self::int(), self::string()), + 'pdo_pgsql' => null, // COALESCE types cannot be matched + 'pgsql' => null, // COALESCE types cannot be matched + 'mssql' => self::mixed(), + 'mysqlResult' => '9', + 'sqliteResult' => 9, + 'pdoPgsqlResult' => null, + 'pgsqlResult' => null, + 'mssqlResult' => 9, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + yield 'IDENTITY(t.related_entity)' => [ 'data' => self::dataDefault(), 'select' => 'SELECT IDENTITY(t.related_entity) FROM %s t', diff --git a/tests/Platform/README.md b/tests/Platform/README.md index 8784f49e..d3678117 100644 --- a/tests/Platform/README.md +++ b/tests/Platform/README.md @@ -21,5 +21,5 @@ You can also run utilize those containers for PHPStorm PHPUnit configuration. Since the dataset is huge and takes few minutes to run, you can filter only functions you are interested in: ```sh -`docker-compose -f tests/Platform/docker/docker-compose.yml run --rm php81 php -d memory_limit=1G vendor/bin/phpunit --group=platform --filter "AVG"` +docker-compose -f tests/Platform/docker/docker-compose.yml run --rm php81 php -d memory_limit=1G vendor/bin/phpunit --group=platform --filter "AVG" ```