diff --git a/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/DateAdd.php b/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/DateAdd.php new file mode 100644 index 00000000..55d8b111 --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/DateAdd.php @@ -0,0 +1,45 @@ + + * + * @example Using it in DQL: "SELECT DATE_ADD(e.timestampWithTz, '1 day', 'Europe/Sofia') FROM Entity e" + */ +class DateAdd extends BaseVariadicFunction +{ + use TimezoneValidationTrait; + + protected function customizeFunction(): void + { + $this->setFunctionPrototype('date_add(%s)'); + } + + protected function validateArguments(Node ...$arguments): void + { + $argumentCount = \count($arguments); + if ($argumentCount < 2 || $argumentCount > 3) { + throw InvalidArgumentForVariadicFunctionException::between('date_add', 2, 3); + } + + // Validate that the third parameter is a valid timezone if provided + if ($argumentCount === 3) { + $this->validateTimezone($arguments[2], 'DATE_ADD'); + } + } +} diff --git a/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/DateBin.php b/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/DateBin.php new file mode 100644 index 00000000..c89de923 --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/DateBin.php @@ -0,0 +1,28 @@ + + * + * @example Using it in DQL: "SELECT DATE_BIN('15 minutes', e.createdAt, '2001-02-16 20:05:00') FROM Entity e" + */ +class DateBin extends BaseFunction +{ + protected function customizeFunction(): void + { + $this->setFunctionPrototype('date_bin(%s, %s, %s)'); + $this->addNodeMapping('StringPrimary'); + $this->addNodeMapping('StringPrimary'); + $this->addNodeMapping('StringPrimary'); + } +} diff --git a/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/DateSubtract.php b/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/DateSubtract.php new file mode 100644 index 00000000..31d0f0e3 --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/DateSubtract.php @@ -0,0 +1,45 @@ + + * + * @example Using it in DQL: "SELECT DATE_SUBTRACT(e.timestampWithTz, '1 day', 'Europe/Sofia') FROM Entity e" + */ +class DateSubtract extends BaseVariadicFunction +{ + use TimezoneValidationTrait; + + protected function customizeFunction(): void + { + $this->setFunctionPrototype('date_subtract(%s)'); + } + + protected function validateArguments(Node ...$arguments): void + { + $argumentCount = \count($arguments); + if ($argumentCount < 2 || $argumentCount > 3) { + throw InvalidArgumentForVariadicFunctionException::between('date_subtract', 2, 3); + } + + // Validate that the third parameter is a valid timezone if provided + if ($argumentCount === 3) { + $this->validateTimezone($arguments[2], 'DATE_SUBTRACT'); + } + } +} diff --git a/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Exception/InvalidTimezoneException.php b/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Exception/InvalidTimezoneException.php new file mode 100644 index 00000000..ca22036f --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Exception/InvalidTimezoneException.php @@ -0,0 +1,33 @@ + + */ +class InvalidTimezoneException extends ConversionException +{ + public static function forNonLiteralNode(string $nodeClass, string $functionName): self + { + return new self(\sprintf( + 'The timezone parameter for %s must be a string literal, got %s', + $functionName, + $nodeClass + )); + } + + public static function forInvalidTimezone(string $timezone, string $functionName): self + { + return new self(\sprintf( + 'Invalid timezone "%s" provided for %s. Must be a valid PHP timezone identifier.', + $timezone, + $functionName + )); + } +} diff --git a/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Traits/TimezoneValidationTrait.php b/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Traits/TimezoneValidationTrait.php new file mode 100644 index 00000000..0bbdc048 --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Traits/TimezoneValidationTrait.php @@ -0,0 +1,48 @@ + + */ +trait TimezoneValidationTrait +{ + /** + * Validates that the given node represents a valid PHP timezone. + * + * @throws InvalidTimezoneException If the timezone is invalid + */ + protected function validateTimezone(Node $node, string $functionName): void + { + if (!$node instanceof Literal || !\is_string($node->value)) { + throw InvalidTimezoneException::forNonLiteralNode($node::class, $functionName); + } + + $timezone = \trim((string) $node->value, "'\""); + + if (!$this->isValidTimezone($timezone)) { + throw InvalidTimezoneException::forInvalidTimezone($timezone, $functionName); + } + } + + private function isValidTimezone(string $timezone): bool + { + try { + new \DateTimeZone($timezone); + + return true; + } catch (\Exception) { + return false; + } + } +} diff --git a/tests/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/DateAddTest.php b/tests/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/DateAddTest.php new file mode 100644 index 00000000..67ecad2f --- /dev/null +++ b/tests/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/DateAddTest.php @@ -0,0 +1,67 @@ + DateAdd::class, + ]; + } + + protected function getExpectedSqlStatements(): array + { + return [ + 'adds 1 day with timezone' => "SELECT date_add(c0_.datetimetz1, '1 day', 'Europe/Sofia') AS sclr_0 FROM ContainsDates c0_", + 'adds 2 hours with timezone' => "SELECT date_add(c0_.datetimetz1, '2 hours', 'UTC') AS sclr_0 FROM ContainsDates c0_", + 'adds 3 days without timezone' => "SELECT date_add(c0_.datetimetz1, '3 days') AS sclr_0 FROM ContainsDates c0_", + 'adds with WHERE clause' => "SELECT c0_.datetimetz1 AS datetimetz1_0 FROM ContainsDates c0_ WHERE date_add(c0_.datetimetz1, '1 day') = '2023-01-02 00:00:00'", + ]; + } + + protected function getDqlStatements(): array + { + return [ + 'adds 1 day with timezone' => \sprintf("SELECT DATE_ADD(e.datetimetz1, '1 day', 'Europe/Sofia') FROM %s e", ContainsDates::class), + 'adds 2 hours with timezone' => \sprintf("SELECT DATE_ADD(e.datetimetz1, '2 hours', 'UTC') FROM %s e", ContainsDates::class), + 'adds 3 days without timezone' => \sprintf("SELECT DATE_ADD(e.datetimetz1, '3 days') FROM %s e", ContainsDates::class), + 'adds with WHERE clause' => \sprintf("SELECT e.datetimetz1 FROM %s e WHERE DATE_ADD(e.datetimetz1, '1 day') = '2023-01-02 00:00:00'", ContainsDates::class), + ]; + } + + public function test_invalid_timezone_throws_exception(): void + { + $this->expectException(InvalidTimezoneException::class); + $this->expectExceptionMessage('Invalid timezone "Invalid/Timezone" provided for DATE_ADD'); + + $dql = \sprintf("SELECT DATE_ADD(e.datetimetz1, '1 day', 'Invalid/Timezone') FROM %s e", ContainsDates::class); + $this->buildEntityManager()->createQuery($dql)->getSQL(); + } + + public function test_too_few_arguments_throws_exception(): void + { + $this->expectException(InvalidArgumentForVariadicFunctionException::class); + $this->expectExceptionMessage('date_add() requires between 2 and 3 arguments'); + + $dql = \sprintf('SELECT DATE_ADD(e.datetimetz1) FROM %s e', ContainsDates::class); + $this->buildEntityManager()->createQuery($dql)->getSQL(); + } + + public function test_too_many_arguments_throws_exception(): void + { + $this->expectException(InvalidArgumentForVariadicFunctionException::class); + $this->expectExceptionMessage('date_add() requires between 2 and 3 arguments'); + + $dql = \sprintf("SELECT DATE_ADD(e.datetimetz1, '1 day', 'Europe/Sofia', 'extra_arg') FROM %s e", ContainsDates::class); + $this->buildEntityManager()->createQuery($dql)->getSQL(); + } +} diff --git a/tests/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/DateBinTest.php b/tests/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/DateBinTest.php new file mode 100644 index 00000000..129d4b65 --- /dev/null +++ b/tests/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/DateBinTest.php @@ -0,0 +1,36 @@ + DateBin::class, + ]; + } + + protected function getExpectedSqlStatements(): array + { + return [ + 'bins by 15 minutes' => "SELECT date_bin('15 minutes', c0_.datetime1, '2001-02-16 20:05:00') AS sclr_0 FROM ContainsDates c0_", + 'bins by 1 day' => "SELECT date_bin('1 day', c0_.datetime1, '2001-02-16 00:00:00') AS sclr_0 FROM ContainsDates c0_", + 'bins with native function as parameter' => "SELECT date_bin('1 hour', CURRENT_TIMESTAMP, '2001-02-16 00:00:00') AS sclr_0 FROM ContainsDates c0_", + ]; + } + + protected function getDqlStatements(): array + { + return [ + 'bins by 15 minutes' => \sprintf("SELECT DATE_BIN('15 minutes', e.datetime1, '2001-02-16 20:05:00') FROM %s e", ContainsDates::class), + 'bins by 1 day' => \sprintf("SELECT DATE_BIN('1 day', e.datetime1, '2001-02-16 00:00:00') FROM %s e", ContainsDates::class), + 'bins with native function as parameter' => \sprintf("SELECT DATE_BIN('1 hour', CURRENT_TIMESTAMP(), '2001-02-16 00:00:00') FROM %s e", ContainsDates::class), + ]; + } +} diff --git a/tests/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/DateSubtractTest.php b/tests/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/DateSubtractTest.php new file mode 100644 index 00000000..73f1409a --- /dev/null +++ b/tests/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/DateSubtractTest.php @@ -0,0 +1,65 @@ + DateSubtract::class, + ]; + } + + protected function getExpectedSqlStatements(): array + { + return [ + 'subtracts 1 day with timezone' => "SELECT date_subtract(c0_.datetimetz1, '1 day', 'Europe/Sofia') AS sclr_0 FROM ContainsDates c0_", + 'subtracts 2 hours with timezone' => "SELECT date_subtract(c0_.datetimetz1, '2 hours', 'UTC') AS sclr_0 FROM ContainsDates c0_", + 'subtracts 3 days without timezone' => "SELECT date_subtract(c0_.datetimetz1, '3 days') AS sclr_0 FROM ContainsDates c0_", + ]; + } + + protected function getDqlStatements(): array + { + return [ + 'subtracts 1 day with timezone' => \sprintf("SELECT DATE_SUBTRACT(e.datetimetz1, '1 day', 'Europe/Sofia') FROM %s e", ContainsDates::class), + 'subtracts 2 hours with timezone' => \sprintf("SELECT DATE_SUBTRACT(e.datetimetz1, '2 hours', 'UTC') FROM %s e", ContainsDates::class), + 'subtracts 3 days without timezone' => \sprintf("SELECT DATE_SUBTRACT(e.datetimetz1, '3 days') FROM %s e", ContainsDates::class), + ]; + } + + public function test_invalid_timezone_throws_exception(): void + { + $this->expectException(InvalidTimezoneException::class); + $this->expectExceptionMessage('Invalid timezone "Invalid/Timezone" provided for DATE_SUBTRACT'); + + $dql = \sprintf("SELECT DATE_SUBTRACT(e.datetimetz1, '1 day', 'Invalid/Timezone') FROM %s e", ContainsDates::class); + $this->buildEntityManager()->createQuery($dql)->getSQL(); + } + + public function test_too_few_arguments_throws_exception(): void + { + $this->expectException(InvalidArgumentForVariadicFunctionException::class); + $this->expectExceptionMessage('date_subtract() requires between 2 and 3 arguments'); + + $dql = \sprintf('SELECT DATE_SUBTRACT(e.datetimetz1) FROM %s e', ContainsDates::class); + $this->buildEntityManager()->createQuery($dql)->getSQL(); + } + + public function test_too_many_arguments_throws_exception(): void + { + $this->expectException(InvalidArgumentForVariadicFunctionException::class); + $this->expectExceptionMessage('date_subtract() requires between 2 and 3 arguments'); + + $dql = \sprintf("SELECT DATE_SUBTRACT(e.datetimetz1, '1 day', 'Europe/Sofia', 'extra_arg') FROM %s e", ContainsDates::class); + $this->buildEntityManager()->createQuery($dql)->getSQL(); + } +} diff --git a/tests/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/TestCase.php b/tests/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/TestCase.php index d43e18df..62b42b71 100644 --- a/tests/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/TestCase.php +++ b/tests/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/TestCase.php @@ -113,7 +113,7 @@ protected function assertSqlFromDql(string $expectedSql, string $dql, string $me self::assertEquals($expectedSql, $query->getSQL(), $message); } - private function buildEntityManager(): EntityManager + protected function buildEntityManager(): EntityManager { return new EntityManager(DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true], $this->configuration), $this->configuration); }