Skip to content

Commit 01b564a

Browse files
feat: add support for DATE_TRUNC (#493)
Co-authored-by: Martin Georgiev <martin-georgiev@users.noreply.github.com>
1 parent 2f317e1 commit 01b564a

File tree

8 files changed

+225
-0
lines changed

8 files changed

+225
-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: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace MartinGeorgiev\Doctrine\ORM\Query\AST\Functions;
6+
7+
use Doctrine\ORM\Query\AST\Node;
8+
use MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Traits\TimezoneValidationTrait;
9+
10+
/**
11+
* Implementation of PostgreSQL DATE_TRUNC().
12+
*
13+
* @see https://www.postgresql.org/docs/current/functions-datetime.html#FUNCTIONS-DATETIME-TRUNC
14+
* @since 3.7
15+
*
16+
* @author Jan Klan <jan@klan.com.au>
17+
*
18+
* @example Using it in DQL: "SELECT DATE_TRUNC('day', e.timestampWithTz, 'Australia/Adelaide') FROM Entity e"
19+
*/
20+
class DateTrunc extends BaseVariadicFunction
21+
{
22+
use TimezoneValidationTrait;
23+
24+
protected function getNodeMappingPattern(): array
25+
{
26+
return ['StringPrimary'];
27+
}
28+
29+
protected function getFunctionName(): string
30+
{
31+
return 'date_trunc';
32+
}
33+
34+
protected function getMinArgumentCount(): int
35+
{
36+
return 2;
37+
}
38+
39+
protected function getMaxArgumentCount(): int
40+
{
41+
return 3;
42+
}
43+
44+
protected function validateArguments(Node ...$arguments): void
45+
{
46+
parent::validateArguments(...$arguments);
47+
48+
// Validate that the third parameter is a valid timezone if provided
49+
if (\count($arguments) === 3) {
50+
$this->validateTimezone($arguments[2], $this->getFunctionName());
51+
}
52+
}
53+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Integration\MartinGeorgiev\Doctrine\ORM\Query\AST\Functions;
6+
7+
use MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\DateTrunc;
8+
use PHPUnit\Framework\Attributes\DataProvider;
9+
use PHPUnit\Framework\Attributes\Test;
10+
11+
class DateTruncTest extends DateTestCase
12+
{
13+
protected function getStringFunctions(): array
14+
{
15+
return [
16+
'DATE_TRUNC' => DateTrunc::class,
17+
];
18+
}
19+
20+
#[DataProvider('provideTruncFieldCases')]
21+
#[Test]
22+
public function can_truncate_to_field(string $field, string $expected): void
23+
{
24+
$dql = \sprintf(
25+
"SELECT DATE_TRUNC('%s', t.datetime1) as result
26+
FROM Fixtures\\MartinGeorgiev\\Doctrine\\Entity\\ContainsDates t
27+
WHERE t.id = 1",
28+
$field
29+
);
30+
$result = $this->executeDqlQuery($dql);
31+
$this->assertSame($expected, $result[0]['result']);
32+
}
33+
34+
/**
35+
* @return \Generator<string, array{string, string}>
36+
*/
37+
public static function provideTruncFieldCases(): \Generator
38+
{
39+
yield 'microseconds' => ['microseconds', '2023-06-15 10:30:00'];
40+
yield 'milliseconds' => ['milliseconds', '2023-06-15 10:30:00'];
41+
yield 'second' => ['second', '2023-06-15 10:30:00'];
42+
yield 'minute' => ['minute', '2023-06-15 10:30:00'];
43+
yield 'hour' => ['hour', '2023-06-15 10:00:00'];
44+
yield 'day' => ['day', '2023-06-15 00:00:00'];
45+
yield 'week' => ['week', '2023-06-12 00:00:00']; // Monday of that week
46+
yield 'month' => ['month', '2023-06-01 00:00:00'];
47+
yield 'quarter' => ['quarter', '2023-04-01 00:00:00'];
48+
yield 'year' => ['year', '2023-01-01 00:00:00'];
49+
yield 'decade' => ['decade', '2020-01-01 00:00:00'];
50+
yield 'century' => ['century', '2001-01-01 00:00:00'];
51+
yield 'millennium' => ['millennium', '2001-01-01 00:00:00'];
52+
}
53+
54+
#[Test]
55+
public function can_truncate_timestamptz_with_timezone(): void
56+
{
57+
$dql = "SELECT DATE_TRUNC('day', t.datetimetz1, 'Australia/Adelaide') as result
58+
FROM Fixtures\\MartinGeorgiev\\Doctrine\\Entity\\ContainsDates t
59+
WHERE t.id = 1";
60+
$result = $this->executeDqlQuery($dql);
61+
// The input is '2023-06-15 10:30:00+00' (UTC)
62+
// In Australia/Adelaide (UTC+9:30), this is '2023-06-15 20:00:00'
63+
// Truncated to day in Adelaide timezone gives '2023-06-15 00:00:00+09:30'
64+
// Converted back to UTC: '2023-06-14 14:30:00+00'
65+
$this->assertSame('2023-06-14 14:30:00+00', $result[0]['result']);
66+
}
67+
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
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 PHPUnit\Framework\Attributes\DataProvider;
13+
use PHPUnit\Framework\Attributes\Test;
14+
15+
class DateTruncTest extends BaseVariadicFunctionTestCase
16+
{
17+
protected function createFixture(): BaseVariadicFunction
18+
{
19+
return new DateTrunc('DATE_TRUNC');
20+
}
21+
22+
protected function getStringFunctions(): array
23+
{
24+
return [
25+
'DATE_TRUNC' => DateTrunc::class,
26+
];
27+
}
28+
29+
protected function getExpectedSqlStatements(): array
30+
{
31+
return [
32+
'with timezone' => /* @lang PostgreSQL */ "SELECT date_trunc('day', c0_.datetimetz1, 'Australia/Adelaide') AS sclr_0 FROM ContainsDates c0_",
33+
'without timezone' => /* @lang PostgreSQL */ "SELECT date_trunc('day', c0_.datetimetz1) AS sclr_0 FROM ContainsDates c0_",
34+
'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'",
35+
];
36+
}
37+
38+
protected function getDqlStatements(): array
39+
{
40+
return [
41+
'with timezone' => \sprintf("SELECT DATE_TRUNC('day', e.datetimetz1, 'Australia/Adelaide') FROM %s e", ContainsDates::class),
42+
'without timezone' => \sprintf("SELECT DATE_TRUNC('day', e.datetimetz1) FROM %s e", ContainsDates::class),
43+
'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),
44+
];
45+
}
46+
47+
#[DataProvider('provideInvalidArgumentCountCases')]
48+
#[Test]
49+
public function throws_exception_for_invalid_argument_count(string $dql, string $expectedMessage): void
50+
{
51+
$this->expectException(InvalidArgumentForVariadicFunctionException::class);
52+
$this->expectExceptionMessage($expectedMessage);
53+
54+
$this->buildEntityManager()->createQuery($dql)->getSQL();
55+
}
56+
57+
/**
58+
* @return array<string, array{string, string}>
59+
*/
60+
public static function provideInvalidArgumentCountCases(): array
61+
{
62+
return [
63+
'too few arguments' => [
64+
\sprintf("SELECT DATE_TRUNC('day') FROM %s e", ContainsDates::class),
65+
'date_trunc() requires at least 2 arguments',
66+
],
67+
'too many arguments' => [
68+
\sprintf("SELECT DATE_TRUNC('day', e.datetimetz1, 'Australia/Adelaide', 'extra_arg') FROM %s e", ContainsDates::class),
69+
'date_trunc() requires between 2 and 3 arguments',
70+
],
71+
];
72+
}
73+
74+
#[DataProvider('provideInvalidTimezoneValues')]
75+
#[Test]
76+
public function throws_exception_for_invalid_timezone(string $invalidTimezone): void
77+
{
78+
$this->expectException(InvalidTimezoneException::class);
79+
$this->expectExceptionMessage(\sprintf('Invalid timezone "%s" provided for date_trunc. Must be a valid PHP timezone identifier.', $invalidTimezone));
80+
81+
$dql = \sprintf("SELECT DATE_TRUNC('day', e.datetimetz1, '%s') FROM %s e", $invalidTimezone, ContainsDates::class);
82+
$this->buildEntityManager()->createQuery($dql)->getSQL();
83+
}
84+
85+
/**
86+
* @return array<string, array{string}>
87+
*/
88+
public static function provideInvalidTimezoneValues(): array
89+
{
90+
return [
91+
'empty string' => [''],
92+
'whitespace only' => [' '],
93+
'numeric value' => ['123'],
94+
'invalid timezone' => ['Invalid/Timezone'],
95+
];
96+
}
97+
}

0 commit comments

Comments
 (0)