Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/DATE-AND-RANGE-FUNCTIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
1 change: 1 addition & 0 deletions docs/INTEGRATING-WITH-DOCTRINE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions docs/INTEGRATING-WITH-LARAVEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions docs/INTEGRATING-WITH-SYMFONY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions docs/USE-CASES-AND-EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
69 changes: 69 additions & 0 deletions src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/DateTrunc.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<?php

declare(strict_types=1);

namespace MartinGeorgiev\Doctrine\ORM\Query\AST\Functions;

use Doctrine\ORM\Query\AST\Literal;
use Doctrine\ORM\Query\AST\Node;
use MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Exception\InvalidTruncFieldException;
use MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Traits\TimezoneValidationTrait;

/**
* Implementation of PostgreSQL DATE_TRUNC().
*
* @see https://www.postgresql.org/docs/current/functions-datetime.html#FUNCTIONS-DATETIME-TRUNC
* @since 3.7
*
* @author Jan Klan <jan@klan.com.au>
*
* @example Using it in DQL: "SELECT DATE_TRUNC('day', e.timestampWithTz, 'Australia/Adelaide') FROM Entity e"
*/
class DateTrunc extends BaseVariadicFunction
{
use TimezoneValidationTrait;

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());
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

declare(strict_types=1);

namespace MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Exception;

use Doctrine\DBAL\Types\ConversionException;
use MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\DateTrunc;

/**
* @since 3.7
*
* @author Jan Klan <jan@klan.com.au>
*/
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
));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<?php

declare(strict_types=1);

namespace Tests\Integration\MartinGeorgiev\Doctrine\ORM\Query\AST\Functions;

use MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\DateTrunc;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;

class DateTruncTest extends DateTestCase
{
protected function getStringFunctions(): array
{
return [
'DATE_TRUNC' => DateTrunc::class,
];
}

#[DataProvider('provideTruncFieldCases')]
#[Test]
public function can_truncate_to_field(string $field, string $expected): void
{
$dql = \sprintf(
"SELECT DATE_TRUNC('%s', t.datetime1) as result
FROM Fixtures\\MartinGeorgiev\\Doctrine\\Entity\\ContainsDates t
WHERE t.id = 1",
$field
);
$result = $this->executeDqlQuery($dql);
$this->assertSame($expected, $result[0]['result']);
}

/**
* @return \Generator<string, array{string, string}>
*/
public static function provideTruncFieldCases(): \Generator
{
// Test data: datetime1 = '2023-06-15 10:30:00'
yield 'microseconds' => ['microseconds', '2023-06-15 10:30:00'];
yield 'milliseconds' => ['milliseconds', '2023-06-15 10:30:00'];
yield 'second' => ['second', '2023-06-15 10:30:00'];
yield 'minute' => ['minute', '2023-06-15 10:30:00'];
yield 'hour' => ['hour', '2023-06-15 10:00:00'];
yield 'day' => ['day', '2023-06-15 00:00:00'];
yield 'week' => ['week', '2023-06-12 00:00:00']; // Monday of that week
yield 'month' => ['month', '2023-06-01 00:00:00'];
yield 'quarter' => ['quarter', '2023-04-01 00:00:00'];
yield 'year' => ['year', '2023-01-01 00:00:00'];
yield 'decade' => ['decade', '2020-01-01 00:00:00'];
yield 'century' => ['century', '2001-01-01 00:00:00'];
yield 'millennium' => ['millennium', '2001-01-01 00:00:00'];
}

#[Test]
public function can_truncate_timestamptz_with_timezone(): void
{
$dql = "SELECT DATE_TRUNC('day', t.datetimetz1, 'Australia/Adelaide') as result
FROM Fixtures\\MartinGeorgiev\\Doctrine\\Entity\\ContainsDates t
WHERE t.id = 1";
$result = $this->executeDqlQuery($dql);
// The input is '2023-06-15 10:30:00+00' (UTC)
// In Australia/Adelaide (UTC+9:30), this is '2023-06-15 20:00:00'
// Truncated to day in Adelaide timezone gives '2023-06-15 00:00:00+09:30'
// Converted back to UTC: '2023-06-14 14:30:00+00'
$this->assertSame('2023-06-14 14:30:00+00', $result[0]['result']);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
<?php

declare(strict_types=1);

namespace Tests\Unit\MartinGeorgiev\Doctrine\ORM\Query\AST\Functions;

use Fixtures\MartinGeorgiev\Doctrine\Entity\ContainsDates;
use MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\BaseVariadicFunction;
use MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\DateTrunc;
use MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Exception\InvalidArgumentForVariadicFunctionException;
use MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Exception\InvalidTimezoneException;
use MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Exception\InvalidTruncFieldException;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;

class DateTruncTest extends BaseVariadicFunctionTestCase
{
protected function createFixture(): BaseVariadicFunction
{
return new DateTrunc('DATE_TRUNC');
}

protected function getStringFunctions(): array
{
return [
'DATE_TRUNC' => 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<string, array{string, string}>
*/
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',
],
];
}

/**
* @return array<string, array{string}>
*/
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<string, array{string}>
*/
public static function provideInvalidTimezoneValues(): array
{
return [
'empty string' => [''],
'whitespace only' => [' '],
'numeric value' => ['123'],
'invalid timezone' => ['Invalid/Timezone'],
];
}
}
Loading