From ed9f9fccd46a59fa2eed834eda8327546146eba8 Mon Sep 17 00:00:00 2001 From: dineshkrishnan24 Date: Wed, 3 Sep 2025 22:26:40 +0530 Subject: [PATCH 01/13] Update Min & Max function to compare with null values. --- .../Expression/FunctionEvaluator.php | 20 +- tests/FunctionEvaluatorTest.php | 179 ++++++++++++++++++ 2 files changed, 195 insertions(+), 4 deletions(-) create mode 100644 tests/FunctionEvaluatorTest.php diff --git a/src/Processor/Expression/FunctionEvaluator.php b/src/Processor/Expression/FunctionEvaluator.php index 8294c504..16e2a28b 100644 --- a/src/Processor/Expression/FunctionEvaluator.php +++ b/src/Processor/Expression/FunctionEvaluator.php @@ -435,14 +435,20 @@ private static function sqlMin( $value = Evaluator::evaluate($conn, $scope, $expr, $row, $result); - if (!\is_scalar($value)) { + if (!\is_scalar($value) && !\is_null($value)) { throw new \TypeError('Bad min value'); } $values[] = $value; } - return self::castAggregate(\min($values), $expr, $result); + $min_value = \min($values); + + if ($min_value === null) { + return null; + } + + return self::castAggregate($min_value, $expr, $result); } /** @@ -470,14 +476,20 @@ private static function sqlMax( $value = Evaluator::evaluate($conn, $scope, $expr, $row, $result); - if (!\is_scalar($value)) { + if (!\is_scalar($value) && !\is_null($value)) { throw new \TypeError('Bad max value'); } $values[] = $value; } - return self::castAggregate(\max($values), $expr, $result); + $max_value = \max($values); + + if ($max_value === null) { + return null; + } + + return self::castAggregate($max_value, $expr, $result); } /** diff --git a/tests/FunctionEvaluatorTest.php b/tests/FunctionEvaluatorTest.php new file mode 100644 index 00000000..ea0b1a2f --- /dev/null +++ b/tests/FunctionEvaluatorTest.php @@ -0,0 +1,179 @@ + ['SELECT ?', 1], + ':field' => ['SELECT :field', ':field'], + 'field' => ['SELECT :field', 'field'], + ]; + } + + /** + * @dataProvider maxValueProvider + */ + public function testSqlMax(array $rows, ?int $expected) : void + { + $conn = $this->createMock(FakePdoInterface::class); + $scope = $this->createMock(Scope::class); + $queryResult = $this->createMock(QueryResult::class); + /** @var array> $rows */ + $queryResult->rows = $rows; + + $token = new \Vimeo\MysqlEngine\Parser\Token( + \Vimeo\MysqlEngine\TokenType::SQLFUNCTION, + 'MAX', + 'MAX', + 0 + ); + + $exp = new ColumnExpression( + new \Vimeo\MysqlEngine\Parser\Token(\Vimeo\MysqlEngine\TokenType::IDENTIFIER, "value", '', 0) + ); + + $functionExpr = new FunctionExpression( + $token, + [$exp], + false + ); + + $refMethod = new \ReflectionMethod(FunctionEvaluator::class, 'sqlMax'); + $refMethod->setAccessible(true); + + if ($expected === -1) { + $this->expectException(\TypeError::class); + $this->expectExceptionMessage('Bad max value'); + } + + /** @var int|null $actual */ + $actual = $refMethod->invoke(null, $conn, $scope, $functionExpr, $queryResult); + + if ($expected !== -1) { + $this->assertSame($expected, $actual); + } + } + + /** + * @dataProvider minValueProvider + */ + public function testSqlMin(array $rows, ?int $expected) : void + { + $conn = $this->createMock(FakePdoInterface::class); + $scope = $this->createMock(Scope::class); + $queryResult = $this->createMock(QueryResult::class); + /** @var array> $rows */ + $queryResult->rows = $rows; + + $token = new \Vimeo\MysqlEngine\Parser\Token( + \Vimeo\MysqlEngine\TokenType::SQLFUNCTION, + 'MIN', + 'MIN', + 0 + ); + + $exp = new ColumnExpression( + new \Vimeo\MysqlEngine\Parser\Token(\Vimeo\MysqlEngine\TokenType::IDENTIFIER, "value", '', 0) + ); + + $functionExpr = new FunctionExpression( + $token, + [$exp], + false + ); + + $refMethod = new \ReflectionMethod(FunctionEvaluator::class, 'sqlMin'); + $refMethod->setAccessible(true); + + if ($expected === -1) { + $this->expectException(\TypeError::class); + $this->expectExceptionMessage('Bad min value'); + } + + /** @var int|null $actual */ + $actual = $refMethod->invoke(null, $conn, $scope, $functionExpr, $queryResult); + + if ($expected !== -1) { + $this->assertSame($expected, $actual); + } + } + + + public static function maxValueProvider(): array + { + return [ + 'null when no rows' => [ + 'rows' => [], + 'expected' => null, + ], + 'max of scalar values' => [ + 'rows' => [ + ['value' => 10], + ['value' => 25], + ['value' => 5], + ], + 'expected' => 25, + ], + 'null values mixed in' => [ + 'rows' => [ + ['value' => null], + ['value' => 7], + ['value' => null], + ], + 'expected' => 7, + ], + 'non scalar values' => [ + 'rows' => [ + ['value' => ['test']], + ], + 'expected' => -1, + ], + ]; + } + + public static function minValueProvider(): array + { + return [ + 'null when no rows' => [ + 'rows' => [], + 'expected' => null, + ], + 'min of scalar values' => [ + 'rows' => [ + ['value' => 10], + ['value' => 25], + ['value' => 5], + ], + 'expected' => 5, + ], + 'null values mixed in' => [ + 'rows' => [ + ['value' => null], + ['value' => 7], + ['value' => null], + ], + 'expected' => null, + ], + 'non scalar values' => [ + 'rows' => [ + ['value' => ['test']], + ], + 'expected' => -1, + ], + ]; + } +} \ No newline at end of file From 6fda7afcff9ba755129fdf2ce50eaeb4ac507b91 Mon Sep 17 00:00:00 2001 From: dineshkrishnan24 Date: Tue, 9 Sep 2025 00:56:46 +0530 Subject: [PATCH 02/13] test case updated as per review suggestion --- tests/FunctionEvaluatorTest.php | 211 ++++++++++++-------------------- 1 file changed, 77 insertions(+), 134 deletions(-) diff --git a/tests/FunctionEvaluatorTest.php b/tests/FunctionEvaluatorTest.php index ea0b1a2f..473c47dc 100644 --- a/tests/FunctionEvaluatorTest.php +++ b/tests/FunctionEvaluatorTest.php @@ -5,175 +5,118 @@ namespace Vimeo\MysqlEngine\Tests; use PHPUnit\Framework\TestCase; -use Vimeo\MysqlEngine\FakePdoInterface; -use Vimeo\MysqlEngine\Processor\Expression\FunctionEvaluator; -use Vimeo\MysqlEngine\Processor\QueryResult; -use Vimeo\MysqlEngine\Processor\Scope; -use Vimeo\MysqlEngine\Query\Expression\ColumnExpression; -use Vimeo\MysqlEngine\Query\Expression\FunctionExpression; class FunctionEvaluatorTest extends TestCase { - public function dataFunction(): array + public function tearDown() : void { - return [ - 'numeric' => ['SELECT ?', 1], - ':field' => ['SELECT :field', ':field'], - 'field' => ['SELECT :field', 'field'], - ]; + \Vimeo\MysqlEngine\Server::reset(); } /** * @dataProvider maxValueProvider */ - public function testSqlMax(array $rows, ?int $expected) : void - { - $conn = $this->createMock(FakePdoInterface::class); - $scope = $this->createMock(Scope::class); - $queryResult = $this->createMock(QueryResult::class); - /** @var array> $rows */ - $queryResult->rows = $rows; - - $token = new \Vimeo\MysqlEngine\Parser\Token( - \Vimeo\MysqlEngine\TokenType::SQLFUNCTION, - 'MAX', - 'MAX', - 0 - ); - - $exp = new ColumnExpression( - new \Vimeo\MysqlEngine\Parser\Token(\Vimeo\MysqlEngine\TokenType::IDENTIFIER, "value", '', 0) - ); - - $functionExpr = new FunctionExpression( - $token, - [$exp], - false - ); - - $refMethod = new \ReflectionMethod(FunctionEvaluator::class, 'sqlMax'); - $refMethod->setAccessible(true); - - if ($expected === -1) { - $this->expectException(\TypeError::class); - $this->expectExceptionMessage('Bad max value'); - } - - /** @var int|null $actual */ - $actual = $refMethod->invoke(null, $conn, $scope, $functionExpr, $queryResult); - - if ($expected !== -1) { - $this->assertSame($expected, $actual); - } - } - - /** - * @dataProvider minValueProvider - */ - public function testSqlMin(array $rows, ?int $expected) : void + public function testSqlMax(string $sql, ?string $expected, bool $is_db_number) : void { - $conn = $this->createMock(FakePdoInterface::class); - $scope = $this->createMock(Scope::class); - $queryResult = $this->createMock(QueryResult::class); - /** @var array> $rows */ - $queryResult->rows = $rows; - - $token = new \Vimeo\MysqlEngine\Parser\Token( - \Vimeo\MysqlEngine\TokenType::SQLFUNCTION, - 'MIN', - 'MIN', - 0 - ); - - $exp = new ColumnExpression( - new \Vimeo\MysqlEngine\Parser\Token(\Vimeo\MysqlEngine\TokenType::IDENTIFIER, "value", '', 0) - ); - - $functionExpr = new FunctionExpression( - $token, - [$exp], - false - ); - - $refMethod = new \ReflectionMethod(FunctionEvaluator::class, 'sqlMin'); - $refMethod->setAccessible(true); - - if ($expected === -1) { - $this->expectException(\TypeError::class); - $this->expectExceptionMessage('Bad min value'); - } - - /** @var int|null $actual */ - $actual = $refMethod->invoke(null, $conn, $scope, $functionExpr, $queryResult); - - if ($expected !== -1) { - $this->assertSame($expected, $actual); + $query = self::getConnectionToFullDB()->prepare($sql); + $query->execute(); + /** @var array> $result */ + $result = $query->fetchAll(\PDO::FETCH_ASSOC); + + if ($is_db_number) { + $this->assertNotEmpty($result); + $this->assertNotNull($result[0]['max']); + } else { + $this->assertSame([['max' => $expected]], $result); } } - public static function maxValueProvider(): array { return [ 'null when no rows' => [ - 'rows' => [], + 'sql' => 'SELECT MAX(null) as `max` FROM `video_game_characters`', 'expected' => null, + 'is_db_number' => false, ], 'max of scalar values' => [ - 'rows' => [ - ['value' => 10], - ['value' => 25], - ['value' => 5], - ], - 'expected' => 25, - ], - 'null values mixed in' => [ - 'rows' => [ - ['value' => null], - ['value' => 7], - ['value' => null], - ], - 'expected' => 7, + 'sql' => 'SELECT MAX(10) as `max` FROM `video_game_characters`', + 'expected' => '10', + 'is_db_number' => false, ], - 'non scalar values' => [ - 'rows' => [ - ['value' => ['test']], - ], - 'expected' => -1, + 'max in DB values' => [ + 'sql' => 'SELECT MAX(id) as `max` FROM `video_game_characters`', + 'expected' => '', + 'is_db_number' => true, ], ]; } + /** + * @dataProvider minValueProvider + */ + public function testSqlMin(string $sql, ?string $expected, bool $is_db_number) : void + { + $query = self::getConnectionToFullDB()->prepare($sql); + $query->execute(); + /** @var array> $result */ + $result = $query->fetchAll(\PDO::FETCH_ASSOC); + + if ($is_db_number) { + $this->assertNotEmpty($result); + $this->assertNotNull($result[0]['min']); + } else { + $this->assertSame([['min' => $expected]], $result); + } + } + public static function minValueProvider(): array { return [ 'null when no rows' => [ - 'rows' => [], + 'sql' => 'SELECT MIN(null) as `min` FROM `video_game_characters`', 'expected' => null, + 'is_db_number' => false, ], 'min of scalar values' => [ - 'rows' => [ - ['value' => 10], - ['value' => 25], - ['value' => 5], - ], - 'expected' => 5, - ], - 'null values mixed in' => [ - 'rows' => [ - ['value' => null], - ['value' => 7], - ['value' => null], - ], - 'expected' => null, + 'sql' => 'SELECT MIN(10) as `min` FROM `video_game_characters`', + 'expected' => '10', + 'is_db_number' => false, ], - 'non scalar values' => [ - 'rows' => [ - ['value' => ['test']], - ], - 'expected' => -1, + 'min in DB values' => [ + 'sql' => 'SELECT MIN(id) as `min` FROM `video_game_characters`', + 'expected' => '', + 'is_db_number' => true, ], ]; } + + private static function getPdo(string $connection_string, bool $strict_mode = false) : \PDO + { + $options = $strict_mode ? [\PDO::MYSQL_ATTR_INIT_COMMAND => 'SET sql_mode="STRICT_ALL_TABLES"'] : []; + + if (\PHP_MAJOR_VERSION === 8) { + return new \Vimeo\MysqlEngine\Php8\FakePdo($connection_string, '', '', $options); + } + + return new \Vimeo\MysqlEngine\Php7\FakePdo($connection_string, '', '', $options); + } + + private static function getConnectionToFullDB(bool $emulate_prepares = true, bool $strict_mode = false) : \PDO + { + $pdo = self::getPdo('mysql:foo;dbname=test;', $strict_mode); + + $pdo->setAttribute(\PDO::ATTR_EMULATE_PREPARES, $emulate_prepares); + + // create table + $pdo->prepare(file_get_contents(__DIR__ . '/fixtures/create_table.sql'))->execute(); + + // insertData + $pdo->prepare(file_get_contents(__DIR__ . '/fixtures/bulk_character_insert.sql'))->execute(); + $pdo->prepare(file_get_contents(__DIR__ . '/fixtures/bulk_enemy_insert.sql'))->execute(); + $pdo->prepare(file_get_contents(__DIR__ . '/fixtures/bulk_tag_insert.sql'))->execute(); + + return $pdo; + } } \ No newline at end of file From 675df9edfe794a6d593f80d0a98eed92f959c794 Mon Sep 17 00:00:00 2001 From: Taras Omelianchuk Date: Mon, 22 Sep 2025 16:33:27 +0300 Subject: [PATCH 03/13] feat: Add support for TIMESTAMPDIFF() function --- .../Expression/FunctionEvaluator.php | 73 ++++++++++++ tests/EndToEndTest.php | 112 +++++++++++++++++- 2 files changed, 184 insertions(+), 1 deletion(-) diff --git a/src/Processor/Expression/FunctionEvaluator.php b/src/Processor/Expression/FunctionEvaluator.php index 16e2a28b..995eb41f 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 'TIMESTAMPDIFF': + return self::sqlTimestampdiff($conn, $scope, $expr, $row, $result); case 'DATEDIFF': return self::sqlDateDiff($conn, $scope, $expr, $row, $result); case 'DAY': @@ -1545,4 +1547,75 @@ private static function getPhpIntervalFromExpression( throw new ProcessorException('MySQL INTERVAL unit ' . $expr->unit . ' not supported yet'); } } + + /** + * @param FakePdoInterface $conn + * @param Scope $scope + * @param FunctionExpression $expr + * @param array $row + * @param QueryResult $result + * + * @return int + * @throws ProcessorException + */ + private static function sqlTimestampdiff( + FakePdoInterface $conn, + Scope $scope, + FunctionExpression $expr, + array $row, + QueryResult $result + ) { + $args = $expr->args; + + if (\count($args) !== 3) { + throw new ProcessorException("MySQL TIMESTAMPDIFF() function must be called with three arguments"); + } + + if (!$args[0] instanceof ColumnExpression) { + throw new ProcessorException("MySQL TIMESTAMPDIFF() function should be called with a unit for interval"); + } + + /** @var string|null $unit */ + $unit = $args[0]->columnExpression; + /** @var string|int|float|null $start */ + $start = Evaluator::evaluate($conn, $scope, $args[1], $row, $result); + /** @var string|int|float|null $end */ + $end = Evaluator::evaluate($conn, $scope, $args[2], $row, $result); + + try { + $dtStart = new \DateTime((string) $start); + $dtEnd = new \DateTime((string) $end); + } catch (\Exception $e) { + throw new ProcessorException("Invalid datetime value passed to TIMESTAMPDIFF()"); + } + + $interval = $dtStart->diff($dtEnd); + + // Calculate difference in seconds for fine-grained units + $seconds = $dtEnd->getTimestamp() - $dtStart->getTimestamp(); + + switch (strtoupper((string)$unit)) { + case 'MICROSECOND': + return $seconds * 1000000; + case 'SECOND': + return $seconds; + case 'MINUTE': + return (int) floor($seconds / 60); + case 'HOUR': + return (int) floor($seconds / 3600); + case 'DAY': + return (int) $interval->days * ($seconds < 0 ? -1 : 1); + case 'WEEK': + return (int) floor($interval->days / 7) * ($seconds < 0 ? -1 : 1); + case 'MONTH': + return ($interval->y * 12 + $interval->m) * ($seconds < 0 ? -1 : 1); + case 'QUARTER': + $months = $interval->y * 12 + $interval->m; + return (int) floor($months / 3) * ($seconds < 0 ? -1 : 1); + case 'YEAR': + return $interval->y * ($seconds < 0 ? -1 : 1); + default: + throw new ProcessorException("Unsupported unit '$unit' in TIMESTAMPDIFF()"); + } + } } diff --git a/tests/EndToEndTest.php b/tests/EndToEndTest.php index 1dad87e3..57080158 100644 --- a/tests/EndToEndTest.php +++ b/tests/EndToEndTest.php @@ -2,6 +2,9 @@ namespace Vimeo\MysqlEngine\Tests; use PDOException; +use Vimeo\MysqlEngine\Parser\Token; +use Vimeo\MysqlEngine\Query\Expression\ColumnExpression; +use Vimeo\MysqlEngine\TokenType; class EndToEndTest extends \PHPUnit\Framework\TestCase { @@ -530,6 +533,113 @@ public function testDateArithhmetic() ); } + /** + * Test various timestamp differences using the TIMESTAMPDIFF function. + * + * This method verifies the calculation of differences in seconds, minutes, + * hours, days, months, and years. + */ + public function testTimestampDiff(): void + { + // Get a PDO instance for MySQL. + $pdo = self::getPdo('mysql:host=localhost;dbname=testdb'); + $pdo->setAttribute(\PDO::ATTR_EMULATE_PREPARES, false); + + // Prepare a single query with multiple TIMESTAMPDIFF calls. + $query = $pdo->prepare( + 'SELECT + TIMESTAMPDIFF(SECOND, \'2020-01-01 00:00:00\', \'2020-01-01 00:01:40\') as `second_diff`, + TIMESTAMPDIFF(MINUTE, \'2020-01-01 00:00:00\', \'2020-01-01 01:30:00\') as `minute_diff`, + TIMESTAMPDIFF(HOUR, \'2020-01-02 00:00:00\', \'2020-01-01 00:00:00\') as `hour_diff`, + TIMESTAMPDIFF(DAY, \'2020-01-01\', \'2020-01-10\') as `day_diff`, + TIMESTAMPDIFF(MONTH, \'2019-01-01\', \'2020-04-01\') as `month_diff`, + TIMESTAMPDIFF(YEAR, \'2010-05-15\', \'2020-05-15\') as `year_diff`' + ); + + $query->execute(); + + $results = $query->fetchAll(\PDO::FETCH_ASSOC); + $castedResults = array_map(function($row) { + return array_map('intval', $row); + }, $results); + + $this->assertSame( + [[ + 'second_diff' => 100, + 'minute_diff' => 90, + 'hour_diff' => -24, + 'day_diff' => 9, + 'month_diff' => 15, + 'year_diff' => 10, + ]], + $castedResults + ); + } + + public function testTimestampDiffThrowsExceptionWithWrongArgumentCount(): void + { + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('MySQL TIMESTAMPDIFF() function must be called with three arguments'); + + $pdo = self::getPdo('mysql:host=localhost;dbname=testdb'); + $pdo->setAttribute(\PDO::ATTR_EMULATE_PREPARES, false); + + $query = $pdo->prepare( + 'SELECT + TIMESTAMPDIFF(SECOND, \'2020-01-01 00:00:00\', \'2020-01-01 00:01:40\', \'2020-01-01 00:01:40\')', + ); + + $query->execute(); + } + + public function testTimestampDiffThrowsExceptionIfFirstArgNotColumnExpression(): void + { + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('MySQL TIMESTAMPDIFF() function should be called with a unit for interval'); + + $pdo = self::getPdo('mysql:host=localhost;dbname=testdb'); + $pdo->setAttribute(\PDO::ATTR_EMULATE_PREPARES, false); + + $query = $pdo->prepare( + 'SELECT + TIMESTAMPDIFF(\'2020-01-01 00:00:00\', \'2020-01-01 00:01:40\', \'2020-01-01 00:01:40\')', + ); + + $query->execute(); + } + + public function testTimestampDiffThrowsExceptionWithWrongDates(): void + { + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('Invalid datetime value passed to TIMESTAMPDIFF()'); + + $pdo = self::getPdo('mysql:host=localhost;dbname=testdb'); + $pdo->setAttribute(\PDO::ATTR_EMULATE_PREPARES, false); + + $query = $pdo->prepare( + 'SELECT + TIMESTAMPDIFF(SECOND, \'2020-01-01 00:0140\', \'2020-01-01 00:01:40\')', + ); + + $query->execute(); + } + + public function testTimestampDiffThrowsExceptionWithWrongInterval(): void + { + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('Unsupported unit \'CENTURY\' in TIMESTAMPDIFF()'); + + $pdo = self::getPdo('mysql:host=localhost;dbname=testdb'); + $pdo->setAttribute(\PDO::ATTR_EMULATE_PREPARES, false); + + $query = $pdo->prepare( + 'SELECT + TIMESTAMPDIFF(CENTURY, \'2020-01-01 00:01:40\', \'2020-01-01 00:01:40\')', + ); + + $query->execute(); + } + public function testCurDateFunction() { $pdo = self::getPdo('mysql:foo'); @@ -1221,7 +1331,7 @@ public function testUpdate() $query->execute(); $this->assertSame([['type' => 'villain']], $query->fetchAll(\PDO::FETCH_ASSOC)); } - + public function testNegateOperationWithAnd() { // greater than From fcb2f632a1e8076ddfe57b2b9a7df88572f3100c Mon Sep 17 00:00:00 2001 From: Taras Omelianchuk Date: Mon, 22 Sep 2025 19:28:16 +0300 Subject: [PATCH 04/13] ci: Disable Xdebug for PHPUnit to resolve dynamic property error --- .github/workflows/phpunit.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml index b04aaa6d..8ff67292 100644 --- a/.github/workflows/phpunit.yml +++ b/.github/workflows/phpunit.yml @@ -10,6 +10,8 @@ jobs: build: runs-on: ubuntu-latest + env: + XDEBUG_MODE: off steps: - uses: actions/checkout@v2 From 122a89e0923db62920577982188e17c9d5246513 Mon Sep 17 00:00:00 2001 From: anton-skochko_Vimeo Date: Tue, 23 Sep 2025 14:12:17 +0300 Subject: [PATCH 05/13] feat: adds LEAST function --- .../Expression/FunctionEvaluator.php | 58 ++++++++++++++++ tests/EndToEndTest.php | 66 +++++++++++++++++++ 2 files changed, 124 insertions(+) diff --git a/src/Processor/Expression/FunctionEvaluator.php b/src/Processor/Expression/FunctionEvaluator.php index 995eb41f..931bccf0 100644 --- a/src/Processor/Expression/FunctionEvaluator.php +++ b/src/Processor/Expression/FunctionEvaluator.php @@ -116,6 +116,8 @@ public static function evaluate( return self::sqlInetAton($conn, $scope, $expr, $row, $result); case 'INET_NTOA': return self::sqlInetNtoa($conn, $scope, $expr, $row, $result); + case 'LEAST': + return self::sqlLeast($conn, $scope, $expr, $row, $result); } throw new ProcessorException("Function " . $expr->functionName . " not implemented yet"); @@ -1618,4 +1620,60 @@ private static function sqlTimestampdiff( throw new ProcessorException("Unsupported unit '$unit' in TIMESTAMPDIFF()"); } } + + /** + * @param FakePdoInterface $conn + * @param Scope $scope + * @param FunctionExpression $expr + * @param array $row + * @param QueryResult $result + * + * @return int + * @throws ProcessorException + */ + private static function sqlLeast( + FakePdoInterface $conn, + Scope $scope, + FunctionExpression $expr, + array $row, + QueryResult $result + ) + { + $args = $expr->args; + + if (\count($args) < 2) { + throw new ProcessorException("Incorrect parameter count in the call to native function 'LEAST'"); + } + + $is_any_float = false; + $is_any_string = false; + $precision = 1; + $evaluated_args = []; + + foreach ($args as $arg) { + $evaluated_arg = Evaluator::evaluate($conn, $scope, $arg, $row, $result); + if (is_null($evaluated_arg)) { + return null; + } + + if (is_float($evaluated_arg)) { + $is_any_float = true; + $precision = max($precision, strlen(substr(strrchr(strval($evaluated_arg), "."), 1))); + } + + $is_any_string = $is_any_string || is_string($evaluated_arg); + $evaluated_args[] = $evaluated_arg; + } + + if ($is_any_string) { + $evaluated_str_args = array_map(fn($evaluated_arg) => strval($evaluated_arg), $evaluated_args); + return min(...$evaluated_str_args); + } + + if ($is_any_float) { + return number_format(min(...$evaluated_args), $precision); + } + + return min(...$evaluated_args); + } } diff --git a/tests/EndToEndTest.php b/tests/EndToEndTest.php index 57080158..0a83f508 100644 --- a/tests/EndToEndTest.php +++ b/tests/EndToEndTest.php @@ -1435,4 +1435,70 @@ private static function getConnectionToFullDB(bool $emulate_prepares = true, boo return $pdo; } + + /** + * @dataProvider leastArgumentsProvider + * @param array $args + * @param string|int|float|null $expected_value + */ + public function testLeast($args, $expected_value): void + { + // Get a PDO instance for MySQL. + $pdo = self::getPdo('mysql:host=localhost;dbname=testdb'); + $pdo->setAttribute(\PDO::ATTR_EMULATE_PREPARES, false); + + $args_str = implode(', ', array_map(fn ($arg) => is_null($arg) ? 'null' : strval($arg), $args)); + $query = $pdo->prepare(sprintf('SELECT LEAST(%s) as result', $args_str),); + $query->execute(); + + $result = $query->fetch(\PDO::FETCH_ASSOC); + $this->assertEquals( + ['result' => $expected_value], $result, + sprintf('Actual result is does not match the expected. Actual is: %s', print_r($result, true))); + } + + public function leastArgumentsProvider(): iterable + { + yield 'Should properly work with at least one \'null\' argument' => [ + 'args' => [1,2,null,42], + 'expected_value' => null + ]; + yield 'Should properly get the least integer argument' => [ + 'args' => [-1, 1,2,42], + 'expected_value' => '-1' + ]; + yield 'Should properly work with decimal argument' => [ + 'args' => [0.00, 0.1,2,42, -0.001], + 'expected_value' => '-0.001' + ]; + yield 'Should return proper precision if any argument is a float' => [ + 'args' => [1, 2.0001 , 42, 1.001], + 'expected_value' => '1.0000' + ]; + yield 'Should properly work with at least one string argument' => [ + 'args' => [1,2, "'null'", "'nulla'"], + 'expected_value' => '1' + ]; + } + + /** @dataProvider leastWithExceptionProvider */ + public function testLeastThrowsExceptionWithWrongArgumentCount(array $args): void + { + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('Incorrect parameter count in the call to native function \'LEAST\''); + + $pdo = self::getPdo('mysql:host=localhost;dbname=testdb'); + $pdo->setAttribute(\PDO::ATTR_EMULATE_PREPARES, false); + + $args_str = implode(', ', array_map(fn ($arg) => strval($arg), $args)); + $query = $pdo->prepare(sprintf('SELECT LEAST(%s)', $args_str),); + + $query->execute(); + } + + public function leastWithExceptionProvider(): iterable + { + yield ['Should fail with single argument' => [1]]; + yield ['Should fail without any arguments' => []]; + } } From 4698fa04f13611bb1d0313c485b7beca89b6a4ac Mon Sep 17 00:00:00 2001 From: 1okey Date: Tue, 23 Sep 2025 16:02:05 +0300 Subject: [PATCH 06/13] fix: make default precision equal to 0 --- src/Processor/Expression/FunctionEvaluator.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Processor/Expression/FunctionEvaluator.php b/src/Processor/Expression/FunctionEvaluator.php index 931bccf0..d3ada82f 100644 --- a/src/Processor/Expression/FunctionEvaluator.php +++ b/src/Processor/Expression/FunctionEvaluator.php @@ -1647,7 +1647,7 @@ private static function sqlLeast( $is_any_float = false; $is_any_string = false; - $precision = 1; + $precision = 0; $evaluated_args = []; foreach ($args as $arg) { From 98965c1008576648eb5bb22c10e111685e57c3f4 Mon Sep 17 00:00:00 2001 From: 1okey Date: Tue, 23 Sep 2025 16:04:55 +0300 Subject: [PATCH 07/13] feat: additional test cases to ensure strings are compared properly --- tests/EndToEndTest.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/EndToEndTest.php b/tests/EndToEndTest.php index 0a83f508..6a5171cc 100644 --- a/tests/EndToEndTest.php +++ b/tests/EndToEndTest.php @@ -1479,6 +1479,18 @@ public function leastArgumentsProvider(): iterable 'args' => [1,2, "'null'", "'nulla'"], 'expected_value' => '1' ]; + yield 'Should properly work all string args' => [ + 'args' => ["'A'","'B'","'C'"], + 'expected_value' => 'A' + ]; + yield 'Should lexicographically compare #1' => [ + 'args' => ["'AA'","'AB'","'AC'"], + 'expected_value' => 'AA' + ]; + yield 'Should lexicographically compare #2' => [ + 'args' => ["'AA'","'AB'","'AC'", 1], + 'expected_value' => '1' + ]; } /** @dataProvider leastWithExceptionProvider */ From 9c21b416b609300dd9543d8ddbc20646801d5c30 Mon Sep 17 00:00:00 2001 From: Taras Omelianchuk Date: Tue, 23 Sep 2025 13:23:43 +0300 Subject: [PATCH 08/13] feat: Add support for CONVERT_TZ() function --- .../Expression/FunctionEvaluator.php | 45 +++++++++++++++++++ tests/FunctionEvaluatorTest.php | 41 +++++++++++++++++ 2 files changed, 86 insertions(+) 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); From 710e8eb29c060e42d9383e4bbc46f8bf9baa24e4 Mon Sep 17 00:00:00 2001 From: 1okey Date: Tue, 23 Sep 2025 16:26:46 +0300 Subject: [PATCH 09/13] fix: address psalm issues raised by the introduction of LEAST function --- src/Processor/Expression/FunctionEvaluator.php | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/Processor/Expression/FunctionEvaluator.php b/src/Processor/Expression/FunctionEvaluator.php index cc9acf85..60c81b6d 100644 --- a/src/Processor/Expression/FunctionEvaluator.php +++ b/src/Processor/Expression/FunctionEvaluator.php @@ -1673,7 +1673,7 @@ private static function sqlTimestampdiff( * @param array $row * @param QueryResult $result * - * @return int + * @return mixed|null * @throws ProcessorException */ private static function sqlLeast( @@ -1696,6 +1696,7 @@ private static function sqlLeast( $evaluated_args = []; foreach ($args as $arg) { + /** @var string|int|float|null $evaluated_arg */ $evaluated_arg = Evaluator::evaluate($conn, $scope, $arg, $row, $result); if (is_null($evaluated_arg)) { return null; @@ -1703,7 +1704,7 @@ private static function sqlLeast( if (is_float($evaluated_arg)) { $is_any_float = true; - $precision = max($precision, strlen(substr(strrchr(strval($evaluated_arg), "."), 1))); + $precision = max($precision, strlen(substr(strrchr((string) $evaluated_arg, "."), 1))); } $is_any_string = $is_any_string || is_string($evaluated_arg); @@ -1711,14 +1712,16 @@ private static function sqlLeast( } if ($is_any_string) { - $evaluated_str_args = array_map(fn($evaluated_arg) => strval($evaluated_arg), $evaluated_args); - return min(...$evaluated_str_args); + $evaluated_str_args = array_map(function($arg) { + return (string) $arg; + }, $evaluated_args); + return min($evaluated_str_args); } if ($is_any_float) { - return number_format(min(...$evaluated_args), $precision); + return number_format((float) min($evaluated_args), $precision); } - return min(...$evaluated_args); + return min($evaluated_args); } } From 474bf46b608c6286b22123dc5a40ba8554351d99 Mon Sep 17 00:00:00 2001 From: 1okey Date: Tue, 23 Sep 2025 16:30:58 +0300 Subject: [PATCH 10/13] fix: remove arrow functions from EndToEndTest --- tests/EndToEndTest.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/EndToEndTest.php b/tests/EndToEndTest.php index 6a5171cc..bd6118d1 100644 --- a/tests/EndToEndTest.php +++ b/tests/EndToEndTest.php @@ -1447,8 +1447,11 @@ public function testLeast($args, $expected_value): void $pdo = self::getPdo('mysql:host=localhost;dbname=testdb'); $pdo->setAttribute(\PDO::ATTR_EMULATE_PREPARES, false); - $args_str = implode(', ', array_map(fn ($arg) => is_null($arg) ? 'null' : strval($arg), $args)); - $query = $pdo->prepare(sprintf('SELECT LEAST(%s) as result', $args_str),); + $args_str = implode(', ', array_map(function ($arg) { + return is_null($arg) ? 'null' : (string) $arg; + }, $args)); + + $query = $pdo->prepare(sprintf('SELECT LEAST(%s) as result', $args_str)); $query->execute(); $result = $query->fetch(\PDO::FETCH_ASSOC); From 3fd1612d2612d135ffd691507a162a542d2fd36c Mon Sep 17 00:00:00 2001 From: tarasom Date: Wed, 24 Sep 2025 22:37:16 +0300 Subject: [PATCH 11/13] feat: Add support for `@@session.time_zone` system variable --- .../Expression/VariableEvaluator.php | 23 +++++++ tests/VariableEvaluatorTest.php | 66 +++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 tests/VariableEvaluatorTest.php diff --git a/src/Processor/Expression/VariableEvaluator.php b/src/Processor/Expression/VariableEvaluator.php index c029e606..cccf6e0e 100644 --- a/src/Processor/Expression/VariableEvaluator.php +++ b/src/Processor/Expression/VariableEvaluator.php @@ -1,6 +1,8 @@ variableName, '@') === 0) { + return self::getSystemVariable(substr($expr->variableName, 1)); + } + if (\array_key_exists($expr->variableName, $scope->variables)) { return $scope->variables[$expr->variableName]; } return null; } + + /** + * @param string $variableName + * + * @return string + * @throws ProcessorException + */ + private static function getSystemVariable(string $variableName): string + { + switch ($variableName) { + case 'session.time_zone': + return date_default_timezone_get(); + default: + throw new ProcessorException("System variable $variableName is not supported yet!"); + } + } } diff --git a/tests/VariableEvaluatorTest.php b/tests/VariableEvaluatorTest.php new file mode 100644 index 00000000..adb33543 --- /dev/null +++ b/tests/VariableEvaluatorTest.php @@ -0,0 +1,66 @@ +getPdo() + ->prepare('SELECT @@session.time_zone'); + $query->execute(); + $result = $query->fetch(\PDO::FETCH_COLUMN); + + $this->assertSame(date_default_timezone_get(), $result); + } + + public function testNotImplementedGlobalVariable(): void + { + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage("The SQL code SELECT @@collation_server; could not be evaluated"); + + $query = $this->getPdo() + ->prepare('SELECT @@collation_server;'); + $query->execute(); + $result = $query->fetch(\PDO::FETCH_COLUMN); + + $this->assertSame(date_default_timezone_get(), $result); + } + + public function testVariable(): void + { + $sql = " + SELECT (@var := @var + 2) AS `counter` + FROM (SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3) AS `virtual_rows` + CROSS JOIN (SELECT @var := 0) AS `vars`; + "; + + $query = $this->getPdo() + ->prepare($sql); + $query->execute(); + $result = $query->fetchAll(\PDO::FETCH_ASSOC); + $counters = array_map('intval', array_column($result, 'counter')); + $this->assertSame([2,4,6], $counters); + } + + private function getPdo(bool $strict_mode = false): \PDO + { + $connection_string = 'mysql:foo;dbname=test;'; + $options = $strict_mode ? [\PDO::MYSQL_ATTR_INIT_COMMAND => 'SET sql_mode="STRICT_ALL_TABLES"'] : []; + + if (\PHP_MAJOR_VERSION === 8) { + return new \Vimeo\MysqlEngine\Php8\FakePdo($connection_string, '', '', $options); + } + + return new \Vimeo\MysqlEngine\Php7\FakePdo($connection_string, '', '', $options); + } +} From 4dccefa689482af930c3f00fe86c9df6291a8b5d Mon Sep 17 00:00:00 2001 From: Taras Omelianchuk Date: Thu, 25 Sep 2025 14:46:27 +0300 Subject: [PATCH 12/13] fix: Resolve evaluation issues in SUM() and CONVERT_TZ() functions --- .../Expression/FunctionEvaluator.php | 16 +++++++- tests/EndToEndTest.php | 40 +++++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/src/Processor/Expression/FunctionEvaluator.php b/src/Processor/Expression/FunctionEvaluator.php index 60c81b6d..c6db5e8f 100644 --- a/src/Processor/Expression/FunctionEvaluator.php +++ b/src/Processor/Expression/FunctionEvaluator.php @@ -353,9 +353,13 @@ private static function sqlCount( } /** - * @param array $columns + * @param FakePdoInterface $conn + * @param Scope $scope + * @param FunctionExpression $expr + * @param QueryResult $result * - * @return ?numeric + * @return float|int|mixed|string|null + * @throws ProcessorException */ private static function sqlSum( FakePdoInterface $conn, @@ -368,6 +372,10 @@ private static function sqlSum( $sum = 0; if (!$result->rows) { + if ($expr instanceof FunctionExpression) { + return self::evaluate($conn, $scope, $expr, [], $result); + } + return null; } @@ -1575,6 +1583,10 @@ private static function sqlConvertTz( throw new \InvalidArgumentException("CONVERT_TZ() requires exactly 3 arguments"); } + if ($args[0] instanceof ColumnExpression && empty($row)) { + return null; + } + /** @var string|null $dtValue */ $dtValue = Evaluator::evaluate($conn, $scope, $args[0], $row, $result); /** @var string|null $fromTzValue */ diff --git a/tests/EndToEndTest.php b/tests/EndToEndTest.php index bd6118d1..f59be4f0 100644 --- a/tests/EndToEndTest.php +++ b/tests/EndToEndTest.php @@ -1516,4 +1516,44 @@ public function leastWithExceptionProvider(): iterable yield ['Should fail with single argument' => [1]]; yield ['Should fail without any arguments' => []]; } + + public function testNestedFunctions() + { + $pdo = self::getConnectionToFullDB(); + + $query = $pdo->prepare(" + SELECT + SUM( + TIMESTAMPDIFF( + SECOND, + CONVERT_TZ('2025-12-31 22:59:59', 'Europe/Kyiv', 'Europe/Kyiv'), + CONVERT_TZ('2025-12-31 23:59:59', 'Europe/Kyiv', 'Europe/Kyiv') + ) + ) + "); + $query->execute(); + + $this->assertSame(3600, (int)$query->fetchColumn()); + } + + public function testNestedFunctionsFromDB() + { + $pdo = self::getConnectionToFullDB(); + $count = $pdo->query("SELECT COUNT(*) FROM video_game_characters")->fetchColumn(); + + $query = $pdo->prepare(" + SELECT SUM( + TIMESTAMPDIFF( + SECOND, + CONVERT_TZ(`created_on`, 'Europe/Kyiv', 'Europe/Kyiv'), + CONVERT_TZ(`created_on` + INTERVAL 1 SECOND, 'Europe/Kyiv', 'Europe/Kyiv') + ) + ) + FROM `video_game_characters` + "); + + $query->execute(); + + $this->assertSame((int)$count, (int)$query->fetchColumn()); + } } From bfa5473f6b0156c39e5f13f8f7434d6f929f8487 Mon Sep 17 00:00:00 2001 From: Taras Omelianchuk Date: Thu, 25 Sep 2025 19:50:24 +0300 Subject: [PATCH 13/13] fix: Correct SUM() evaluation for empty result sets --- .../Expression/FunctionEvaluator.php | 3 ++- tests/EndToEndTest.php | 26 ++++++++++++++++--- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/Processor/Expression/FunctionEvaluator.php b/src/Processor/Expression/FunctionEvaluator.php index c6db5e8f..b82d27a5 100644 --- a/src/Processor/Expression/FunctionEvaluator.php +++ b/src/Processor/Expression/FunctionEvaluator.php @@ -372,7 +372,8 @@ private static function sqlSum( $sum = 0; if (!$result->rows) { - if ($expr instanceof FunctionExpression) { + $isQueryWithoutFromClause = empty($result->columns); + if ($expr instanceof FunctionExpression && $isQueryWithoutFromClause) { return self::evaluate($conn, $scope, $expr, [], $result); } diff --git a/tests/EndToEndTest.php b/tests/EndToEndTest.php index f59be4f0..1d5352dc 100644 --- a/tests/EndToEndTest.php +++ b/tests/EndToEndTest.php @@ -2,9 +2,6 @@ namespace Vimeo\MysqlEngine\Tests; use PDOException; -use Vimeo\MysqlEngine\Parser\Token; -use Vimeo\MysqlEngine\Query\Expression\ColumnExpression; -use Vimeo\MysqlEngine\TokenType; class EndToEndTest extends \PHPUnit\Framework\TestCase { @@ -13,6 +10,29 @@ public function tearDown() : void \Vimeo\MysqlEngine\Server::reset(); } + public function testSumWithEmptyResultSetReturnsNull() + { + $sql = " + SELECT + SUM( + IF( + id > 1, + 0, + 1 + ) + ) + FROM + video_game_characters + WHERE + id > 100; + "; + + $pdo = self::getConnectionToFullDB(); + $query= $pdo->query($sql); + + $this->assertNull($query->fetchColumn()); + } + public function testSelectEmptyResults() { $pdo = self::getConnectionToFullDB();