Skip to content

Commit d897908

Browse files
committed
(feat) Add support for DATE_TRUNC
1 parent 266820d commit d897908

File tree

8 files changed

+280
-0
lines changed

8 files changed

+280
-0
lines changed

docs/DATE-AND-RANGE-FUNCTIONS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ This document covers PostgreSQL date, time, and range functions available in thi
1111
| date_add | DATE_ADD | `MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\DateAdd` |
1212
| date_bin | DATE_BIN | `MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\DateBin` |
1313
| date_subtract | DATE_SUBTRACT | `MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\DateSubtract` |
14+
| date_trunc | DATE_TRUNC | `MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\DateTrunc` |
1415
| extract | DATE_EXTRACT | `MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\DateExtract` |
1516
| overlaps | DATE_OVERLAPS | `MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\DateOverlaps` |
1617
| to_date | TO_DATE | `MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\ToDate` |

docs/INTEGRATING-WITH-DOCTRINE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,7 @@ $configuration->addCustomStringFunction('DATE_BIN', MartinGeorgiev\Doctrine\ORM\
161161
$configuration->addCustomStringFunction('DATE_EXTRACT', MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\DateExtract::class);
162162
$configuration->addCustomStringFunction('DATE_OVERLAPS', MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\DateOverlaps::class);
163163
$configuration->addCustomStringFunction('DATE_SUBTRACT', MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\DateSubtract::class);
164+
$configuration->addCustomStringFunction('DATE_TRUNC', MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\DateTrunc::class);
164165

165166
# range functions
166167
$configuration->addCustomStringFunction('DATERANGE', MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Daterange::class);

docs/INTEGRATING-WITH-LARAVEL.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,7 @@ return [
246246
'DATE_EXTRACT' => MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\DateExtract::class,
247247
'DATE_OVERLAPS' => MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\DateOverlaps::class,
248248
'DATE_SUBTRACT' => MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\DateSubtract::class,
249+
'DATE_TRUNC' => MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\DateTrunc::class,
249250

250251
# range functions
251252
'DATERANGE' => MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Daterange::class,

docs/INTEGRATING-WITH-SYMFONY.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,7 @@ doctrine:
229229
DATE_EXTRACT: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\DateExtract
230230
DATE_OVERLAPS: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\DateOverlaps
231231
DATE_SUBTRACT: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\DateSubtract
232+
DATE_TRUNC: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\DateTrunc
232233
233234
# range functions
234235
DATERANGE: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Daterange

docs/USE-CASES-AND-EXAMPLES.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,10 @@ SELECT DATE_ADD(e.timestampWithTz, '1 day', 'Europe/London') FROM Entity e
104104
-- Subtract an interval from a timestamp (timezone parameter is optional)
105105
SELECT DATE_SUBTRACT(e.timestampWithTz, '2 hours') FROM Entity e
106106
SELECT DATE_SUBTRACT(e.timestampWithTz, '2 hours', 'UTC') FROM Entity e
107+
108+
-- Truncate a timestamp to a specified precision (timezone parameter is optional)
109+
SELECT DATE_TRUNC('day', e.timestampWithTz) FROM Entity e
110+
SELECT DATE_TRUNC('day', e.timestampWithTz, 'UTC') FROM Entity e
107111
```
108112

109113
Using Range Types
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace MartinGeorgiev\Doctrine\ORM\Query\AST\Functions;
6+
7+
use Doctrine\ORM\Query\AST\Literal;
8+
use Doctrine\ORM\Query\AST\Node;
9+
use MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Exception\InvalidTruncFieldException;
10+
use MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Traits\TimezoneValidationTrait;
11+
12+
/**
13+
* Implementation of PostgreSQL DATE_TRUNC().
14+
*
15+
* @see https://www.postgresql.org/docs/current/functions-datetime.html#FUNCTIONS-DATETIME-TRUNC
16+
* @since 3.7
17+
*
18+
* @author Jan Klan <jan@klan.com.au>
19+
*
20+
* @example Using it in DQL: "SELECT DATE_TRUNC('day', e.timestampWithTz, 'Australia/Adelaide') FROM Entity e"
21+
*/
22+
class DateTrunc extends BaseVariadicFunction
23+
{
24+
use TimezoneValidationTrait;
25+
/**
26+
* @see https://www.postgresql.org/docs/current/functions-datetime.html#FUNCTIONS-DATETIME-TRUNC
27+
*/
28+
public const FIELDS = [
29+
'microseconds',
30+
'milliseconds',
31+
'second',
32+
'minute',
33+
'hour',
34+
'day',
35+
'week',
36+
'month',
37+
'quarter',
38+
'year',
39+
'decade',
40+
'century',
41+
'millennium',
42+
];
43+
44+
protected function getNodeMappingPattern(): array
45+
{
46+
return ['StringPrimary'];
47+
}
48+
49+
protected function getFunctionName(): string
50+
{
51+
return 'date_trunc';
52+
}
53+
54+
protected function getMinArgumentCount(): int
55+
{
56+
return 2;
57+
}
58+
59+
protected function getMaxArgumentCount(): int
60+
{
61+
return 3;
62+
}
63+
64+
protected function validateArguments(Node ...$arguments): void
65+
{
66+
parent::validateArguments(...$arguments);
67+
68+
$this->validateTruncField($arguments[0]);
69+
70+
// Validate that the third parameter is a valid timezone if provided
71+
if (\count($arguments) === 3) {
72+
$this->validateTimezone($arguments[2], $this->getFunctionName());
73+
}
74+
}
75+
76+
/**
77+
* Validates that the given node represents a valid trunc field value.
78+
*
79+
* @throws InvalidTruncFieldException If the field value is invalid
80+
*/
81+
protected function validateTruncField(Node $node): void
82+
{
83+
if (!$node instanceof Literal || !\is_string($node->value)) {
84+
throw InvalidTruncFieldException::forNonLiteralNode($node::class, $this->getFunctionName());
85+
}
86+
87+
$field = \strtolower(\trim((string) $node->value, "'\""));
88+
89+
if (!\in_array($field, self::FIELDS, true)) {
90+
throw InvalidTruncFieldException::forInvalidField($field, $this->getFunctionName());
91+
}
92+
}
93+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Exception;
6+
7+
use Doctrine\DBAL\Types\ConversionException;
8+
use MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\DateTrunc;
9+
10+
/**
11+
* @since 3.7
12+
*
13+
* @author Jan Klan <jan@klan.com.au>
14+
*/
15+
class InvalidTruncFieldException extends ConversionException
16+
{
17+
public static function forNonLiteralNode(string $nodeClass, string $functionName): self
18+
{
19+
return new self(\sprintf(
20+
'The date_trunc field parameter for %s must be a string literal, got %s',
21+
$functionName,
22+
$nodeClass
23+
));
24+
}
25+
26+
public static function forInvalidField(string $field, string $functionName): self
27+
{
28+
return new self(\sprintf(
29+
'Invalid field value "%s" provided for %s. Must be one of: %s.',
30+
$field,
31+
$functionName,
32+
\implode(', ', DateTrunc::FIELDS)
33+
));
34+
}
35+
}
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Unit\MartinGeorgiev\Doctrine\ORM\Query\AST\Functions;
6+
7+
use Fixtures\MartinGeorgiev\Doctrine\Entity\ContainsDates;
8+
use MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\BaseVariadicFunction;
9+
use MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\DateTrunc;
10+
use MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Exception\InvalidArgumentForVariadicFunctionException;
11+
use MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Exception\InvalidTimezoneException;
12+
use MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Exception\InvalidTruncFieldException;
13+
use PHPUnit\Framework\Attributes\DataProvider;
14+
use PHPUnit\Framework\Attributes\Test;
15+
16+
class DateTruncTest extends BaseVariadicFunctionTestCase
17+
{
18+
protected function createFixture(): BaseVariadicFunction
19+
{
20+
return new DateTrunc('DATE_TRUNC');
21+
}
22+
23+
protected function getStringFunctions(): array
24+
{
25+
return [
26+
'DATE_TRUNC' => DateTrunc::class,
27+
];
28+
}
29+
30+
protected function getExpectedSqlStatements(): array
31+
{
32+
return [
33+
'with timezone (3 arguments)' => /* @lang PostgreSQL */ "SELECT date_trunc('day', c0_.datetimetz1, 'Australia/Adelaide') AS sclr_0 FROM ContainsDates c0_",
34+
'without timezone (2 arguments)' => /* @lang PostgreSQL */ "SELECT date_trunc('day', c0_.datetimetz1) AS sclr_0 FROM ContainsDates c0_",
35+
'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'",
36+
];
37+
}
38+
39+
protected function getDqlStatements(): array
40+
{
41+
return [
42+
'with timezone (3 arguments)' => \sprintf("SELECT DATE_TRUNC('day', e.datetimetz1, 'Australia/Adelaide') FROM %s e", ContainsDates::class),
43+
'without timezone (2 arguments)' => \sprintf("SELECT DATE_TRUNC('day', e.datetimetz1) FROM %s e", ContainsDates::class),
44+
'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),
45+
];
46+
}
47+
48+
#[DataProvider('provideInvalidArgumentCountCases')]
49+
#[Test]
50+
public function throws_exception_for_invalid_argument_count(string $dql, string $expectedMessage): void
51+
{
52+
$this->expectException(InvalidArgumentForVariadicFunctionException::class);
53+
$this->expectExceptionMessage($expectedMessage);
54+
55+
$this->buildEntityManager()->createQuery($dql)->getSQL();
56+
}
57+
58+
/**
59+
* @return array<string, array{string, string}>
60+
*/
61+
public static function provideInvalidArgumentCountCases(): array
62+
{
63+
return [
64+
'too few arguments' => [
65+
\sprintf("SELECT DATE_TRUNC('day') FROM %s e", ContainsDates::class),
66+
'date_trunc() requires at least 2 arguments',
67+
],
68+
'too many arguments' => [
69+
\sprintf("SELECT DATE_TRUNC('day', e.datetimetz1, 'Australia/Adelaide', 'extra_arg') FROM %s e", ContainsDates::class),
70+
'date_trunc() requires between 2 and 3 arguments',
71+
],
72+
];
73+
}
74+
75+
#[DataProvider('provideValidFieldValues')]
76+
#[Test]
77+
public function accepts_valid_field_value(string $validField): void
78+
{
79+
$dql = \sprintf("SELECT DATE_TRUNC('%s', e.datetimetz1) FROM %s e", $validField, ContainsDates::class);
80+
81+
$this->assertEquals(
82+
\sprintf("SELECT date_trunc('%s', c0_.datetimetz1) AS sclr_0 FROM ContainsDates c0_", $validField),
83+
$this->buildEntityManager()->createQuery($dql)->getSQL()
84+
);
85+
}
86+
87+
/**
88+
* @return \Generator<string, array{string}>
89+
*/
90+
public static function provideValidFieldValues(): \Generator
91+
{
92+
foreach (DateTrunc::FIELDS as $field) {
93+
yield $field => [$field];
94+
}
95+
}
96+
97+
#[DataProvider('provideInvalidFieldValues')]
98+
#[Test]
99+
public function throws_exception_for_invalid_field(string $invalidField): void
100+
{
101+
$this->expectException(InvalidTruncFieldException::class);
102+
$this->expectExceptionMessage(\sprintf('Invalid field value "%s" provided for date_trunc. Must be one of: %s.', $invalidField, \implode(', ', DateTrunc::FIELDS)));
103+
104+
$dql = \sprintf("SELECT DATE_TRUNC('%s', e.datetimetz1) FROM %s e", $invalidField, ContainsDates::class);
105+
$this->buildEntityManager()->createQuery($dql)->getSQL();
106+
}
107+
108+
/**
109+
* @return array<string, array{string}>
110+
*/
111+
public static function provideInvalidFieldValues(): array
112+
{
113+
return [
114+
'empty string' => [''],
115+
'whitespace only' => [' '],
116+
'numeric value' => ['123'],
117+
'invalid field' => ['invalid'],
118+
];
119+
}
120+
121+
#[DataProvider('provideInvalidTimezoneValues')]
122+
#[Test]
123+
public function throws_exception_for_invalid_timezone(string $invalidTimezone): void
124+
{
125+
$this->expectException(InvalidTimezoneException::class);
126+
$this->expectExceptionMessage(\sprintf('Invalid timezone "%s" provided for date_trunc. Must be a valid PHP timezone identifier.', $invalidTimezone));
127+
128+
$dql = \sprintf("SELECT DATE_TRUNC('day', e.datetimetz1, '%s') FROM %s e", $invalidTimezone, ContainsDates::class);
129+
$this->buildEntityManager()->createQuery($dql)->getSQL();
130+
}
131+
132+
/**
133+
* @return array<string, array{string}>
134+
*/
135+
public static function provideInvalidTimezoneValues(): array
136+
{
137+
return [
138+
'empty string' => [''],
139+
'whitespace only' => [' '],
140+
'numeric value' => ['123'],
141+
'invalid timezone' => ['Invalid/Timezone'],
142+
];
143+
}
144+
}

0 commit comments

Comments
 (0)