diff --git a/docs/DATE-AND-RANGE-FUNCTIONS.md b/docs/DATE-AND-RANGE-FUNCTIONS.md index 84dc3634..3b512d9b 100644 --- a/docs/DATE-AND-RANGE-FUNCTIONS.md +++ b/docs/DATE-AND-RANGE-FUNCTIONS.md @@ -11,6 +11,7 @@ This document covers PostgreSQL date, time, and range functions available in thi | date_add | DATE_ADD | `MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\DateAdd` | | date_bin | DATE_BIN | `MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\DateBin` | | date_subtract | DATE_SUBTRACT | `MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\DateSubtract` | +| date_trunc | DATE_TRUNC | `MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\DateTrunc` | | extract | DATE_EXTRACT | `MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\DateExtract` | | overlaps | DATE_OVERLAPS | `MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\DateOverlaps` | | to_date | TO_DATE | `MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\ToDate` | diff --git a/docs/INTEGRATING-WITH-DOCTRINE.md b/docs/INTEGRATING-WITH-DOCTRINE.md index cdcf3aff..a38e7cff 100644 --- a/docs/INTEGRATING-WITH-DOCTRINE.md +++ b/docs/INTEGRATING-WITH-DOCTRINE.md @@ -161,6 +161,7 @@ $configuration->addCustomStringFunction('DATE_BIN', MartinGeorgiev\Doctrine\ORM\ $configuration->addCustomStringFunction('DATE_EXTRACT', MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\DateExtract::class); $configuration->addCustomStringFunction('DATE_OVERLAPS', MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\DateOverlaps::class); $configuration->addCustomStringFunction('DATE_SUBTRACT', MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\DateSubtract::class); +$configuration->addCustomStringFunction('DATE_TRUNC', MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\DateTrunc::class); # range functions $configuration->addCustomStringFunction('DATERANGE', MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Daterange::class); diff --git a/docs/INTEGRATING-WITH-LARAVEL.md b/docs/INTEGRATING-WITH-LARAVEL.md index 97b2c304..703b2158 100644 --- a/docs/INTEGRATING-WITH-LARAVEL.md +++ b/docs/INTEGRATING-WITH-LARAVEL.md @@ -246,6 +246,7 @@ return [ 'DATE_EXTRACT' => MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\DateExtract::class, 'DATE_OVERLAPS' => MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\DateOverlaps::class, 'DATE_SUBTRACT' => MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\DateSubtract::class, + 'DATE_TRUNC' => MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\DateTrunc::class, # range functions 'DATERANGE' => MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Daterange::class, diff --git a/docs/INTEGRATING-WITH-SYMFONY.md b/docs/INTEGRATING-WITH-SYMFONY.md index 67fa387f..5751d1c0 100644 --- a/docs/INTEGRATING-WITH-SYMFONY.md +++ b/docs/INTEGRATING-WITH-SYMFONY.md @@ -229,6 +229,7 @@ doctrine: DATE_EXTRACT: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\DateExtract DATE_OVERLAPS: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\DateOverlaps DATE_SUBTRACT: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\DateSubtract + DATE_TRUNC: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\DateTrunc # range functions DATERANGE: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Daterange diff --git a/docs/USE-CASES-AND-EXAMPLES.md b/docs/USE-CASES-AND-EXAMPLES.md index 83e42dec..fbebaf10 100644 --- a/docs/USE-CASES-AND-EXAMPLES.md +++ b/docs/USE-CASES-AND-EXAMPLES.md @@ -104,6 +104,10 @@ SELECT DATE_ADD(e.timestampWithTz, '1 day', 'Europe/London') FROM Entity e -- Subtract an interval from a timestamp (timezone parameter is optional) SELECT DATE_SUBTRACT(e.timestampWithTz, '2 hours') FROM Entity e SELECT DATE_SUBTRACT(e.timestampWithTz, '2 hours', 'UTC') FROM Entity e + +-- Truncate a timestamp to a specified precision (timezone parameter is optional) +SELECT DATE_TRUNC('day', e.timestampWithTz) FROM Entity e +SELECT DATE_TRUNC('day', e.timestampWithTz, 'UTC') FROM Entity e ``` Using Range Types diff --git a/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/DateTrunc.php b/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/DateTrunc.php new file mode 100644 index 00000000..dff21d1e --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/DateTrunc.php @@ -0,0 +1,93 @@ + + * + * @example Using it in DQL: "SELECT DATE_TRUNC('day', e.timestampWithTz, 'Australia/Adelaide') FROM Entity e" + */ +class DateTrunc extends BaseVariadicFunction +{ + use TimezoneValidationTrait; + /** + * @see https://www.postgresql.org/docs/current/functions-datetime.html#FUNCTIONS-DATETIME-TRUNC + */ + public const FIELDS = [ + 'microseconds', + 'milliseconds', + 'second', + 'minute', + 'hour', + 'day', + 'week', + 'month', + 'quarter', + 'year', + 'decade', + 'century', + 'millennium', + ]; + + protected function getNodeMappingPattern(): array + { + return ['StringPrimary']; + } + + protected function getFunctionName(): string + { + return 'date_trunc'; + } + + protected function getMinArgumentCount(): int + { + return 2; + } + + protected function getMaxArgumentCount(): int + { + return 3; + } + + protected function validateArguments(Node ...$arguments): void + { + parent::validateArguments(...$arguments); + + $this->validateTruncField($arguments[0]); + + // Validate that the third parameter is a valid timezone if provided + if (\count($arguments) === 3) { + $this->validateTimezone($arguments[2], $this->getFunctionName()); + } + } + + /** + * Validates that the given node represents a valid trunc field value. + * + * @throws InvalidTruncFieldException If the field value is invalid + */ + protected function validateTruncField(Node $node): void + { + if (!$node instanceof Literal || !\is_string($node->value)) { + throw InvalidTruncFieldException::forNonLiteralNode($node::class, $this->getFunctionName()); + } + + $field = \strtolower(\trim((string) $node->value, "'\"")); + + if (!\in_array($field, self::FIELDS, true)) { + throw InvalidTruncFieldException::forInvalidField($field, $this->getFunctionName()); + } + } +} diff --git a/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Exception/InvalidTruncFieldException.php b/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Exception/InvalidTruncFieldException.php new file mode 100644 index 00000000..32dab5dd --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Exception/InvalidTruncFieldException.php @@ -0,0 +1,35 @@ + + */ +class InvalidTruncFieldException extends ConversionException +{ + public static function forNonLiteralNode(string $nodeClass, string $functionName): self + { + return new self(\sprintf( + 'The date_trunc field parameter for %s must be a string literal, got %s', + $functionName, + $nodeClass + )); + } + + public static function forInvalidField(string $field, string $functionName): self + { + return new self(\sprintf( + 'Invalid field value "%s" provided for %s. Must be one of: %s.', + $field, + $functionName, + \implode(', ', DateTrunc::FIELDS) + )); + } +} diff --git a/tests/Unit/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/DateTruncTest.php b/tests/Unit/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/DateTruncTest.php new file mode 100644 index 00000000..5a0ae415 --- /dev/null +++ b/tests/Unit/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/DateTruncTest.php @@ -0,0 +1,144 @@ + DateTrunc::class, + ]; + } + + protected function getExpectedSqlStatements(): array + { + return [ + 'with timezone (3 arguments)' => /* @lang PostgreSQL */ "SELECT date_trunc('day', c0_.datetimetz1, 'Australia/Adelaide') AS sclr_0 FROM ContainsDates c0_", + 'without timezone (2 arguments)' => /* @lang PostgreSQL */ "SELECT date_trunc('day', c0_.datetimetz1) AS sclr_0 FROM ContainsDates c0_", + 'used in WHERE clause' => /* @lang PostgreSQL */ "SELECT c0_.datetimetz1 AS datetimetz1_0 FROM ContainsDates c0_ WHERE date_trunc('day', c0_.datetimetz1) = '2023-01-02 00:00:00'", + ]; + } + + protected function getDqlStatements(): array + { + return [ + 'with timezone (3 arguments)' => \sprintf("SELECT DATE_TRUNC('day', e.datetimetz1, 'Australia/Adelaide') FROM %s e", ContainsDates::class), + 'without timezone (2 arguments)' => \sprintf("SELECT DATE_TRUNC('day', e.datetimetz1) FROM %s e", ContainsDates::class), + 'used in WHERE clause' => \sprintf("SELECT e.datetimetz1 FROM %s e WHERE DATE_TRUNC('day', e.datetimetz1) = '2023-01-02 00:00:00'", ContainsDates::class), + ]; + } + + #[DataProvider('provideInvalidArgumentCountCases')] + #[Test] + public function throws_exception_for_invalid_argument_count(string $dql, string $expectedMessage): void + { + $this->expectException(InvalidArgumentForVariadicFunctionException::class); + $this->expectExceptionMessage($expectedMessage); + + $this->buildEntityManager()->createQuery($dql)->getSQL(); + } + + /** + * @return array + */ + public static function provideInvalidArgumentCountCases(): array + { + return [ + 'too few arguments' => [ + \sprintf("SELECT DATE_TRUNC('day') FROM %s e", ContainsDates::class), + 'date_trunc() requires at least 2 arguments', + ], + 'too many arguments' => [ + \sprintf("SELECT DATE_TRUNC('day', e.datetimetz1, 'Australia/Adelaide', 'extra_arg') FROM %s e", ContainsDates::class), + 'date_trunc() requires between 2 and 3 arguments', + ], + ]; + } + + #[DataProvider('provideValidFieldValues')] + #[Test] + public function accepts_valid_field_value(string $validField): void + { + $dql = \sprintf("SELECT DATE_TRUNC('%s', e.datetimetz1) FROM %s e", $validField, ContainsDates::class); + + $this->assertEquals( + \sprintf("SELECT date_trunc('%s', c0_.datetimetz1) AS sclr_0 FROM ContainsDates c0_", $validField), + $this->buildEntityManager()->createQuery($dql)->getSQL() + ); + } + + /** + * @return \Generator + */ + public static function provideValidFieldValues(): \Generator + { + foreach (DateTrunc::FIELDS as $field) { + yield $field => [$field]; + } + } + + #[DataProvider('provideInvalidFieldValues')] + #[Test] + public function throws_exception_for_invalid_field(string $invalidField): void + { + $this->expectException(InvalidTruncFieldException::class); + $this->expectExceptionMessage(\sprintf('Invalid field value "%s" provided for date_trunc. Must be one of: %s.', $invalidField, \implode(', ', DateTrunc::FIELDS))); + + $dql = \sprintf("SELECT DATE_TRUNC('%s', e.datetimetz1) FROM %s e", $invalidField, ContainsDates::class); + $this->buildEntityManager()->createQuery($dql)->getSQL(); + } + + /** + * @return array + */ + public static function provideInvalidFieldValues(): array + { + return [ + 'empty string' => [''], + 'whitespace only' => [' '], + 'numeric value' => ['123'], + 'invalid field' => ['invalid'], + ]; + } + + #[DataProvider('provideInvalidTimezoneValues')] + #[Test] + public function throws_exception_for_invalid_timezone(string $invalidTimezone): void + { + $this->expectException(InvalidTimezoneException::class); + $this->expectExceptionMessage(\sprintf('Invalid timezone "%s" provided for date_trunc. Must be a valid PHP timezone identifier.', $invalidTimezone)); + + $dql = \sprintf("SELECT DATE_TRUNC('day', e.datetimetz1, '%s') FROM %s e", $invalidTimezone, ContainsDates::class); + $this->buildEntityManager()->createQuery($dql)->getSQL(); + } + + /** + * @return array + */ + public static function provideInvalidTimezoneValues(): array + { + return [ + 'empty string' => [''], + 'whitespace only' => [' '], + 'numeric value' => ['123'], + 'invalid timezone' => ['Invalid/Timezone'], + ]; + } +}