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 diff --git a/src/Processor/Expression/FunctionEvaluator.php b/src/Processor/Expression/FunctionEvaluator.php index 8294c504..b82d27a5 100644 --- a/src/Processor/Expression/FunctionEvaluator.php +++ b/src/Processor/Expression/FunctionEvaluator.php @@ -99,6 +99,10 @@ 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': return self::sqlDateDiff($conn, $scope, $expr, $row, $result); case 'DAY': @@ -114,6 +118,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"); @@ -347,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, @@ -362,6 +372,11 @@ private static function sqlSum( $sum = 0; if (!$result->rows) { + $isQueryWithoutFromClause = empty($result->columns); + if ($expr instanceof FunctionExpression && $isQueryWithoutFromClause) { + return self::evaluate($conn, $scope, $expr, [], $result); + } + return null; } @@ -435,14 +450,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 +491,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); } /** @@ -1533,4 +1560,181 @@ 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 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"); + } + + 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 */ + $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 + * @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()"); + } + } + + /** + * @param FakePdoInterface $conn + * @param Scope $scope + * @param FunctionExpression $expr + * @param array $row + * @param QueryResult $result + * + * @return mixed|null + * @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 = 0; + $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; + } + + if (is_float($evaluated_arg)) { + $is_any_float = true; + $precision = max($precision, strlen(substr(strrchr((string) $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(function($arg) { + return (string) $arg; + }, $evaluated_args); + return min($evaluated_str_args); + } + + if ($is_any_float) { + return number_format((float) min($evaluated_args), $precision); + } + + return min($evaluated_args); + } } 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/EndToEndTest.php b/tests/EndToEndTest.php index 1dad87e3..1d5352dc 100644 --- a/tests/EndToEndTest.php +++ b/tests/EndToEndTest.php @@ -10,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(); @@ -530,6 +553,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 +1351,7 @@ public function testUpdate() $query->execute(); $this->assertSame([['type' => 'villain']], $query->fetchAll(\PDO::FETCH_ASSOC)); } - + public function testNegateOperationWithAnd() { // greater than @@ -1325,4 +1455,125 @@ 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(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); + $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' + ]; + 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 */ + 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' => []]; + } + + 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()); + } } diff --git a/tests/FunctionEvaluatorTest.php b/tests/FunctionEvaluatorTest.php new file mode 100644 index 00000000..cc188dba --- /dev/null +++ b/tests/FunctionEvaluatorTest.php @@ -0,0 +1,163 @@ +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' => [ + 'sql' => 'SELECT MAX(null) as `max` FROM `video_game_characters`', + 'expected' => null, + 'is_db_number' => false, + ], + 'max of scalar values' => [ + 'sql' => 'SELECT MAX(10) as `max` FROM `video_game_characters`', + 'expected' => '10', + 'is_db_number' => false, + ], + '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' => [ + 'sql' => 'SELECT MIN(null) as `min` FROM `video_game_characters`', + 'expected' => null, + 'is_db_number' => false, + ], + 'min of scalar values' => [ + 'sql' => 'SELECT MIN(10) as `min` FROM `video_game_characters`', + 'expected' => '10', + 'is_db_number' => false, + ], + '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); + } + + /** + * @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); + + $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 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); + } +}