diff --git a/src/Processor/Expression/FunctionEvaluator.php b/src/Processor/Expression/FunctionEvaluator.php index 995eb41f..69d2cb3e 100644 --- a/src/Processor/Expression/FunctionEvaluator.php +++ b/src/Processor/Expression/FunctionEvaluator.php @@ -99,6 +99,8 @@ public static function evaluate( return self::sqlCeiling($conn, $scope, $expr, $row, $result); case 'FLOOR': return self::sqlFloor($conn, $scope, $expr, $row, $result); + case 'CONVERT_TZ': + return self::sqlConvertTz($conn, $scope, $expr, $row, $result); case 'TIMESTAMPDIFF': return self::sqlTimestampdiff($conn, $scope, $expr, $row, $result); case 'DATEDIFF': @@ -1548,6 +1550,49 @@ private static function getPhpIntervalFromExpression( } } + /** + * @param FakePdoInterface $conn + * @param Scope $scope + * @param FunctionExpression $expr + * @param array $row + * @param QueryResult $result + * + * @return string|null + * @throws ProcessorException + */ + private static function sqlConvertTz( + FakePdoInterface $conn, + Scope $scope, + FunctionExpression $expr, + array $row, + QueryResult $result) + { + $args = $expr->args; + + if (count($args) !== 3) { + throw new \InvalidArgumentException("CONVERT_TZ() requires exactly 3 arguments"); + } + + /** @var string|null $dtValue */ + $dtValue = Evaluator::evaluate($conn, $scope, $args[0], $row, $result); + /** @var string|null $fromTzValue */ + $fromTzValue = Evaluator::evaluate($conn, $scope, $args[1], $row, $result); + /** @var string|null $toTzValue */ + $toTzValue = Evaluator::evaluate($conn, $scope, $args[2], $row, $result); + + if ($dtValue === null || $fromTzValue === null || $toTzValue === null) { + return null; + } + + try { + $dt = new \DateTime($dtValue, new \DateTimeZone($fromTzValue)); + $dt->setTimezone(new \DateTimeZone($toTzValue)); + return $dt->format('Y-m-d H:i:s'); + } catch (\Exception $e) { + return null; + } + } + /** * @param FakePdoInterface $conn * @param Scope $scope diff --git a/tests/FunctionEvaluatorTest.php b/tests/FunctionEvaluatorTest.php index 473c47dc..cc188dba 100644 --- a/tests/FunctionEvaluatorTest.php +++ b/tests/FunctionEvaluatorTest.php @@ -103,6 +103,47 @@ private static function getPdo(string $connection_string, bool $strict_mode = fa return new \Vimeo\MysqlEngine\Php7\FakePdo($connection_string, '', '', $options); } + /** + * @dataProvider convertTzProvider + */ + public function testConvertTz(string $sql, ?string $expected) + { + $query = self::getConnectionToFullDB()->prepare($sql); + $query->execute(); + + $this->assertSame($expected, $query->fetch(\PDO::FETCH_COLUMN)); + } + + private static function convertTzProvider(): array + { + return [ + 'normal conversion' => [ + 'sql' => "SELECT CONVERT_TZ('2025-09-23 02:30:00', 'UTC', 'Europe/Kyiv');", + 'expected' => "2025-09-23 05:30:00", + ], + 'same tz' => [ + 'sql' => "SELECT CONVERT_TZ('2025-12-31 23:59:59', 'Europe/Kyiv', 'Europe/Kyiv');", + 'expected' => "2025-12-31 23:59:59", + ], + 'crossing DST' => [ + 'sql' => "SELECT CONVERT_TZ('2025-07-01 12:00:00', 'America/New_York', 'UTC');", + 'expected' => "2025-07-01 16:00:00", + ], + 'null date' => [ + 'sql' => "SELECT CONVERT_TZ(NULL, 'UTC', 'Europe/Kyiv');", + 'expected' => null, + ], + 'invalid timezone' => [ + 'sql' => "SELECT CONVERT_TZ('2025-09-23 02:30:00', 'Invalid/Zone', 'UTC');", + 'expected' => null, + ], + 'invalid date' => [ + 'sql' => "SELECT CONVERT_TZ('not-a-date', 'UTC', 'UTC');", + 'expected' => null, + ] + ]; + } + private static function getConnectionToFullDB(bool $emulate_prepares = true, bool $strict_mode = false) : \PDO { $pdo = self::getPdo('mysql:foo;dbname=test;', $strict_mode);