Skip to content

Commit 3e90481

Browse files
committed
feat: Add support for DATE_TRUNC
1 parent 2fcdf95 commit 3e90481

File tree

9 files changed

+281
-0
lines changed

9 files changed

+281
-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: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
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+
protected function getNodeMappingPattern(): array
27+
{
28+
return ['StringPrimary'];
29+
}
30+
31+
protected function getFunctionName(): string
32+
{
33+
return 'date_trunc';
34+
}
35+
36+
protected function getMinArgumentCount(): int
37+
{
38+
return 2;
39+
}
40+
41+
protected function getMaxArgumentCount(): int
42+
{
43+
return 3;
44+
}
45+
46+
protected function validateArguments(Node ...$arguments): void
47+
{
48+
parent::validateArguments(...$arguments);
49+
50+
$this->validateTruncField($arguments[0]);
51+
52+
// Validate that the third parameter is a valid timezone if provided
53+
if (\count($arguments) === 3) {
54+
$this->validateTimezone($arguments[2], $this->getFunctionName());
55+
}
56+
}
57+
58+
/**
59+
* Validates that the given node represents a valid trunc field value.
60+
*
61+
* @throws InvalidTruncFieldException If the field value is invalid
62+
*/
63+
protected function validateTruncField(Node $node): void
64+
{
65+
if (!$node instanceof Literal || !\is_string($node->value)) {
66+
throw InvalidTruncFieldException::forNonLiteralNode($node::class, $this->getFunctionName());
67+
}
68+
}
69+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
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+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
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+
// Test data: datetime1 = '2023-06-15 10:30:00'
40+
yield 'microseconds' => ['microseconds', '2023-06-15 10:30:00'];
41+
yield 'milliseconds' => ['milliseconds', '2023-06-15 10:30:00'];
42+
yield 'second' => ['second', '2023-06-15 10:30:00'];
43+
yield 'minute' => ['minute', '2023-06-15 10:30:00'];
44+
yield 'hour' => ['hour', '2023-06-15 10:00:00'];
45+
yield 'day' => ['day', '2023-06-15 00:00:00'];
46+
yield 'week' => ['week', '2023-06-12 00:00:00']; // Monday of that week
47+
yield 'month' => ['month', '2023-06-01 00:00:00'];
48+
yield 'quarter' => ['quarter', '2023-04-01 00:00:00'];
49+
yield 'year' => ['year', '2023-01-01 00:00:00'];
50+
yield 'decade' => ['decade', '2020-01-01 00:00:00'];
51+
yield 'century' => ['century', '2001-01-01 00:00:00'];
52+
yield 'millennium' => ['millennium', '2001-01-01 00:00:00'];
53+
}
54+
55+
#[Test]
56+
public function can_truncate_timestamptz_with_timezone(): void
57+
{
58+
$dql = "SELECT DATE_TRUNC('day', t.datetimetz1, 'Australia/Adelaide') as result
59+
FROM Fixtures\\MartinGeorgiev\\Doctrine\\Entity\\ContainsDates t
60+
WHERE t.id = 1";
61+
$result = $this->executeDqlQuery($dql);
62+
// The input is '2023-06-15 10:30:00+00' (UTC)
63+
// In Australia/Adelaide (UTC+9:30), this is '2023-06-15 20:00:00'
64+
// Truncated to day in Adelaide timezone gives '2023-06-15 00:00:00+09:30'
65+
// Converted back to UTC: '2023-06-14 14:30:00+00'
66+
$this->assertSame('2023-06-14 14:30:00+00', $result[0]['result']);
67+
}
68+
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
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+
/**
76+
* @return array<string, array{string}>
77+
*/
78+
public static function provideInvalidFieldValues(): array
79+
{
80+
return [
81+
'empty string' => [''],
82+
'whitespace only' => [' '],
83+
'numeric value' => ['123'],
84+
'invalid field' => ['invalid'],
85+
];
86+
}
87+
88+
#[DataProvider('provideInvalidTimezoneValues')]
89+
#[Test]
90+
public function throws_exception_for_invalid_timezone(string $invalidTimezone): void
91+
{
92+
$this->expectException(InvalidTimezoneException::class);
93+
$this->expectExceptionMessage(\sprintf('Invalid timezone "%s" provided for date_trunc. Must be a valid PHP timezone identifier.', $invalidTimezone));
94+
95+
$dql = \sprintf("SELECT DATE_TRUNC('day', e.datetimetz1, '%s') FROM %s e", $invalidTimezone, ContainsDates::class);
96+
$this->buildEntityManager()->createQuery($dql)->getSQL();
97+
}
98+
99+
/**
100+
* @return array<string, array{string}>
101+
*/
102+
public static function provideInvalidTimezoneValues(): array
103+
{
104+
return [
105+
'empty string' => [''],
106+
'whitespace only' => [' '],
107+
'numeric value' => ['123'],
108+
'invalid timezone' => ['Invalid/Timezone'],
109+
];
110+
}
111+
}

0 commit comments

Comments
 (0)