Skip to content

Commit c3cb08d

Browse files
feat: add support for DATE_ADD(), DATE_SUBTRACT() and DATE_BIN() (#345)
1 parent 553a30c commit c3cb08d

File tree

9 files changed

+368
-1
lines changed

9 files changed

+368
-1
lines changed
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
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\Exception\InvalidArgumentForVariadicFunctionException;
9+
use MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Traits\TimezoneValidationTrait;
10+
11+
/**
12+
* Implementation of PostgreSQL DATE_ADD().
13+
*
14+
* Adds an interval to a timestamp with time zone, computing times of day and daylight-savings
15+
* adjustments according to the time zone.
16+
*
17+
* @see https://www.postgresql.org/docs/16/functions-datetime.html
18+
* @since 3.1
19+
*
20+
* @author Martin Georgiev <martin.georgiev@gmail.com>
21+
*
22+
* @example Using it in DQL: "SELECT DATE_ADD(e.timestampWithTz, '1 day', 'Europe/Sofia') FROM Entity e"
23+
*/
24+
class DateAdd extends BaseVariadicFunction
25+
{
26+
use TimezoneValidationTrait;
27+
28+
protected function customizeFunction(): void
29+
{
30+
$this->setFunctionPrototype('date_add(%s)');
31+
}
32+
33+
protected function validateArguments(Node ...$arguments): void
34+
{
35+
$argumentCount = \count($arguments);
36+
if ($argumentCount < 2 || $argumentCount > 3) {
37+
throw InvalidArgumentForVariadicFunctionException::between('date_add', 2, 3);
38+
}
39+
40+
// Validate that the third parameter is a valid timezone if provided
41+
if ($argumentCount === 3) {
42+
$this->validateTimezone($arguments[2], 'DATE_ADD');
43+
}
44+
}
45+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace MartinGeorgiev\Doctrine\ORM\Query\AST\Functions;
6+
7+
/**
8+
* Implementation of PostgreSQL DATE_BIN().
9+
*
10+
* Bins input into specified interval aligned with specified origin.
11+
*
12+
* @see https://www.postgresql.org/docs/14/functions-datetime.html
13+
* @since 3.1
14+
*
15+
* @author Martin Georgiev <martin.georgiev@gmail.com>
16+
*
17+
* @example Using it in DQL: "SELECT DATE_BIN('15 minutes', e.createdAt, '2001-02-16 20:05:00') FROM Entity e"
18+
*/
19+
class DateBin extends BaseFunction
20+
{
21+
protected function customizeFunction(): void
22+
{
23+
$this->setFunctionPrototype('date_bin(%s, %s, %s)');
24+
$this->addNodeMapping('StringPrimary');
25+
$this->addNodeMapping('StringPrimary');
26+
$this->addNodeMapping('StringPrimary');
27+
}
28+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
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\Exception\InvalidArgumentForVariadicFunctionException;
9+
use MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Traits\TimezoneValidationTrait;
10+
11+
/**
12+
* Implementation of PostgreSQL DATE_SUBTRACT().
13+
*
14+
* Subtracts an interval from a timestamp with time zone, computing times of day and daylight-savings
15+
* adjustments according to the time zone.
16+
*
17+
* @see https://www.postgresql.org/docs/16/functions-datetime.html
18+
* @since 3.1
19+
*
20+
* @author Martin Georgiev <martin.georgiev@gmail.com>
21+
*
22+
* @example Using it in DQL: "SELECT DATE_SUBTRACT(e.timestampWithTz, '1 day', 'Europe/Sofia') FROM Entity e"
23+
*/
24+
class DateSubtract extends BaseVariadicFunction
25+
{
26+
use TimezoneValidationTrait;
27+
28+
protected function customizeFunction(): void
29+
{
30+
$this->setFunctionPrototype('date_subtract(%s)');
31+
}
32+
33+
protected function validateArguments(Node ...$arguments): void
34+
{
35+
$argumentCount = \count($arguments);
36+
if ($argumentCount < 2 || $argumentCount > 3) {
37+
throw InvalidArgumentForVariadicFunctionException::between('date_subtract', 2, 3);
38+
}
39+
40+
// Validate that the third parameter is a valid timezone if provided
41+
if ($argumentCount === 3) {
42+
$this->validateTimezone($arguments[2], 'DATE_SUBTRACT');
43+
}
44+
}
45+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
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+
9+
/**
10+
* @since 3.1
11+
*
12+
* @author Martin Georgiev <martin.georgiev@gmail.com>
13+
*/
14+
class InvalidTimezoneException extends ConversionException
15+
{
16+
public static function forNonLiteralNode(string $nodeClass, string $functionName): self
17+
{
18+
return new self(\sprintf(
19+
'The timezone parameter for %s must be a string literal, got %s',
20+
$functionName,
21+
$nodeClass
22+
));
23+
}
24+
25+
public static function forInvalidTimezone(string $timezone, string $functionName): self
26+
{
27+
return new self(\sprintf(
28+
'Invalid timezone "%s" provided for %s. Must be a valid PHP timezone identifier.',
29+
$timezone,
30+
$functionName
31+
));
32+
}
33+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Traits;
6+
7+
use Doctrine\ORM\Query\AST\Literal;
8+
use Doctrine\ORM\Query\AST\Node;
9+
use MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Exception\InvalidTimezoneException;
10+
11+
/**
12+
* Provides timezone validation functionality for functions that use valid PHP timezones.
13+
*
14+
* @since 3.1
15+
*
16+
* @author Martin Georgiev <martin.georgiev@gmail.com>
17+
*/
18+
trait TimezoneValidationTrait
19+
{
20+
/**
21+
* Validates that the given node represents a valid PHP timezone.
22+
*
23+
* @throws InvalidTimezoneException If the timezone is invalid
24+
*/
25+
protected function validateTimezone(Node $node, string $functionName): void
26+
{
27+
if (!$node instanceof Literal || !\is_string($node->value)) {
28+
throw InvalidTimezoneException::forNonLiteralNode($node::class, $functionName);
29+
}
30+
31+
$timezone = \trim((string) $node->value, "'\"");
32+
33+
if (!$this->isValidTimezone($timezone)) {
34+
throw InvalidTimezoneException::forInvalidTimezone($timezone, $functionName);
35+
}
36+
}
37+
38+
private function isValidTimezone(string $timezone): bool
39+
{
40+
try {
41+
new \DateTimeZone($timezone);
42+
43+
return true;
44+
} catch (\Exception) {
45+
return false;
46+
}
47+
}
48+
}
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\MartinGeorgiev\Doctrine\ORM\Query\AST\Functions;
6+
7+
use Fixtures\MartinGeorgiev\Doctrine\Entity\ContainsDates;
8+
use MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\DateAdd;
9+
use MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Exception\InvalidArgumentForVariadicFunctionException;
10+
use MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Exception\InvalidTimezoneException;
11+
12+
class DateAddTest extends TestCase
13+
{
14+
protected function getStringFunctions(): array
15+
{
16+
return [
17+
'DATE_ADD' => DateAdd::class,
18+
];
19+
}
20+
21+
protected function getExpectedSqlStatements(): array
22+
{
23+
return [
24+
'adds 1 day with timezone' => "SELECT date_add(c0_.datetimetz1, '1 day', 'Europe/Sofia') AS sclr_0 FROM ContainsDates c0_",
25+
'adds 2 hours with timezone' => "SELECT date_add(c0_.datetimetz1, '2 hours', 'UTC') AS sclr_0 FROM ContainsDates c0_",
26+
'adds 3 days without timezone' => "SELECT date_add(c0_.datetimetz1, '3 days') AS sclr_0 FROM ContainsDates c0_",
27+
'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'",
28+
];
29+
}
30+
31+
protected function getDqlStatements(): array
32+
{
33+
return [
34+
'adds 1 day with timezone' => \sprintf("SELECT DATE_ADD(e.datetimetz1, '1 day', 'Europe/Sofia') FROM %s e", ContainsDates::class),
35+
'adds 2 hours with timezone' => \sprintf("SELECT DATE_ADD(e.datetimetz1, '2 hours', 'UTC') FROM %s e", ContainsDates::class),
36+
'adds 3 days without timezone' => \sprintf("SELECT DATE_ADD(e.datetimetz1, '3 days') FROM %s e", ContainsDates::class),
37+
'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),
38+
];
39+
}
40+
41+
public function test_invalid_timezone_throws_exception(): void
42+
{
43+
$this->expectException(InvalidTimezoneException::class);
44+
$this->expectExceptionMessage('Invalid timezone "Invalid/Timezone" provided for DATE_ADD');
45+
46+
$dql = \sprintf("SELECT DATE_ADD(e.datetimetz1, '1 day', 'Invalid/Timezone') FROM %s e", ContainsDates::class);
47+
$this->buildEntityManager()->createQuery($dql)->getSQL();
48+
}
49+
50+
public function test_too_few_arguments_throws_exception(): void
51+
{
52+
$this->expectException(InvalidArgumentForVariadicFunctionException::class);
53+
$this->expectExceptionMessage('date_add() requires between 2 and 3 arguments');
54+
55+
$dql = \sprintf('SELECT DATE_ADD(e.datetimetz1) FROM %s e', ContainsDates::class);
56+
$this->buildEntityManager()->createQuery($dql)->getSQL();
57+
}
58+
59+
public function test_too_many_arguments_throws_exception(): void
60+
{
61+
$this->expectException(InvalidArgumentForVariadicFunctionException::class);
62+
$this->expectExceptionMessage('date_add() requires between 2 and 3 arguments');
63+
64+
$dql = \sprintf("SELECT DATE_ADD(e.datetimetz1, '1 day', 'Europe/Sofia', 'extra_arg') FROM %s e", ContainsDates::class);
65+
$this->buildEntityManager()->createQuery($dql)->getSQL();
66+
}
67+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\MartinGeorgiev\Doctrine\ORM\Query\AST\Functions;
6+
7+
use Fixtures\MartinGeorgiev\Doctrine\Entity\ContainsDates;
8+
use MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\DateBin;
9+
10+
class DateBinTest extends TestCase
11+
{
12+
protected function getStringFunctions(): array
13+
{
14+
return [
15+
'DATE_BIN' => DateBin::class,
16+
];
17+
}
18+
19+
protected function getExpectedSqlStatements(): array
20+
{
21+
return [
22+
'bins by 15 minutes' => "SELECT date_bin('15 minutes', c0_.datetime1, '2001-02-16 20:05:00') AS sclr_0 FROM ContainsDates c0_",
23+
'bins by 1 day' => "SELECT date_bin('1 day', c0_.datetime1, '2001-02-16 00:00:00') AS sclr_0 FROM ContainsDates c0_",
24+
'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_",
25+
];
26+
}
27+
28+
protected function getDqlStatements(): array
29+
{
30+
return [
31+
'bins by 15 minutes' => \sprintf("SELECT DATE_BIN('15 minutes', e.datetime1, '2001-02-16 20:05:00') FROM %s e", ContainsDates::class),
32+
'bins by 1 day' => \sprintf("SELECT DATE_BIN('1 day', e.datetime1, '2001-02-16 00:00:00') FROM %s e", ContainsDates::class),
33+
'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),
34+
];
35+
}
36+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\MartinGeorgiev\Doctrine\ORM\Query\AST\Functions;
6+
7+
use Fixtures\MartinGeorgiev\Doctrine\Entity\ContainsDates;
8+
use MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\DateSubtract;
9+
use MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Exception\InvalidArgumentForVariadicFunctionException;
10+
use MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Exception\InvalidTimezoneException;
11+
12+
class DateSubtractTest extends TestCase
13+
{
14+
protected function getStringFunctions(): array
15+
{
16+
return [
17+
'DATE_SUBTRACT' => DateSubtract::class,
18+
];
19+
}
20+
21+
protected function getExpectedSqlStatements(): array
22+
{
23+
return [
24+
'subtracts 1 day with timezone' => "SELECT date_subtract(c0_.datetimetz1, '1 day', 'Europe/Sofia') AS sclr_0 FROM ContainsDates c0_",
25+
'subtracts 2 hours with timezone' => "SELECT date_subtract(c0_.datetimetz1, '2 hours', 'UTC') AS sclr_0 FROM ContainsDates c0_",
26+
'subtracts 3 days without timezone' => "SELECT date_subtract(c0_.datetimetz1, '3 days') AS sclr_0 FROM ContainsDates c0_",
27+
];
28+
}
29+
30+
protected function getDqlStatements(): array
31+
{
32+
return [
33+
'subtracts 1 day with timezone' => \sprintf("SELECT DATE_SUBTRACT(e.datetimetz1, '1 day', 'Europe/Sofia') FROM %s e", ContainsDates::class),
34+
'subtracts 2 hours with timezone' => \sprintf("SELECT DATE_SUBTRACT(e.datetimetz1, '2 hours', 'UTC') FROM %s e", ContainsDates::class),
35+
'subtracts 3 days without timezone' => \sprintf("SELECT DATE_SUBTRACT(e.datetimetz1, '3 days') FROM %s e", ContainsDates::class),
36+
];
37+
}
38+
39+
public function test_invalid_timezone_throws_exception(): void
40+
{
41+
$this->expectException(InvalidTimezoneException::class);
42+
$this->expectExceptionMessage('Invalid timezone "Invalid/Timezone" provided for DATE_SUBTRACT');
43+
44+
$dql = \sprintf("SELECT DATE_SUBTRACT(e.datetimetz1, '1 day', 'Invalid/Timezone') FROM %s e", ContainsDates::class);
45+
$this->buildEntityManager()->createQuery($dql)->getSQL();
46+
}
47+
48+
public function test_too_few_arguments_throws_exception(): void
49+
{
50+
$this->expectException(InvalidArgumentForVariadicFunctionException::class);
51+
$this->expectExceptionMessage('date_subtract() requires between 2 and 3 arguments');
52+
53+
$dql = \sprintf('SELECT DATE_SUBTRACT(e.datetimetz1) FROM %s e', ContainsDates::class);
54+
$this->buildEntityManager()->createQuery($dql)->getSQL();
55+
}
56+
57+
public function test_too_many_arguments_throws_exception(): void
58+
{
59+
$this->expectException(InvalidArgumentForVariadicFunctionException::class);
60+
$this->expectExceptionMessage('date_subtract() requires between 2 and 3 arguments');
61+
62+
$dql = \sprintf("SELECT DATE_SUBTRACT(e.datetimetz1, '1 day', 'Europe/Sofia', 'extra_arg') FROM %s e", ContainsDates::class);
63+
$this->buildEntityManager()->createQuery($dql)->getSQL();
64+
}
65+
}

tests/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/TestCase.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ protected function assertSqlFromDql(string $expectedSql, string $dql, string $me
113113
self::assertEquals($expectedSql, $query->getSQL(), $message);
114114
}
115115

116-
private function buildEntityManager(): EntityManager
116+
protected function buildEntityManager(): EntityManager
117117
{
118118
return new EntityManager(DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true], $this->configuration), $this->configuration);
119119
}

0 commit comments

Comments
 (0)