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
93 changes: 93 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,93 @@
<?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;
/**
* @see https://www.postgresql.org/docs/current/functions-datetime.html#FUNCTIONS-DATETIME-TRUNC
*/
public const FIELDS = [
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you elaborate on why this is a public constant? There is no such pattern in any other classes in the repository. On the surface, it is needed for tests primarily.

Copy link
Contributor Author

@janklan janklan Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wanted to both (a) enumerate the allowed options in InvalidTruncFieldException message and (b) have the list of valid options available for input validation.

It's in a public constant because it will likely never change (hence not a static variable), it's exposed as a class constant because it's needed in two separate classes, and it's not hardcoded as two separate arrays in each class because if it does change, someone will have to remember changing it in both places.

At the end of the day if you think it's better as two separate hardcoded arrays (because it's unlikely to change), let's do it that way for sake of overall consistency.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I prefer PostgreSQL to throw its native error message and wrap it, rather than trying to validate lower-level PostgreSQL behaviour.

'microseconds',
'milliseconds',
'second',
'minute',
'hour',
'day',
'week',
'month',
'quarter',
'year',
'decade',
'century',
'millennium',
];

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());
}

$field = \strtolower(\trim((string) $node->value, "'\""));

if (!\in_array($field, self::FIELDS, true)) {
throw InvalidTruncFieldException::forInvalidField($field, $this->getFunctionName());
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not let PostgreSQL decide whether this is fine? Excessive validation may limit support for future PostgreSQL versions.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Personal preference of code that fails fast - this will fail before any SQL query gets executed.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand your personal preference.
Given that this is OSS, it's better to stay consistent with the repository style. If you think validation should be improved, let's open the topic in the Discussion section so a change in the approach can be synchronised.

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

public static function forInvalidField(string $field, string $functionName): self
{
return new self(\sprintf(
'Invalid field value "%s" provided for %s. Must be one of: %s.',
$field,
$functionName,
\implode(', ', DateTrunc::FIELDS)
));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
<?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',
],
];
}

#[DataProvider('provideValidFieldValues')]
#[Test]
public function accepts_valid_field_value(string $validField): void
{
$dql = \sprintf("SELECT DATE_TRUNC('%s', e.datetimetz1) FROM %s e", $validField, ContainsDates::class);

$this->assertEquals(
\sprintf("SELECT date_trunc('%s', c0_.datetimetz1) AS sclr_0 FROM ContainsDates c0_", $validField),
$this->buildEntityManager()->createQuery($dql)->getSQL()
);
}

/**
* @return \Generator<string, array{string}>
*/
public static function provideValidFieldValues(): \Generator
{
foreach (DateTrunc::FIELDS as $field) {
yield $field => [$field];
}
}

#[DataProvider('provideInvalidFieldValues')]
#[Test]
public function throws_exception_for_invalid_field(string $invalidField): void
{
$this->expectException(InvalidTruncFieldException::class);
$this->expectExceptionMessage(\sprintf('Invalid field value "%s" provided for date_trunc. Must be one of: %s.', $invalidField, \implode(', ', DateTrunc::FIELDS)));

$dql = \sprintf("SELECT DATE_TRUNC('%s', e.datetimetz1) FROM %s e", $invalidField, ContainsDates::class);
$this->buildEntityManager()->createQuery($dql)->getSQL();
}

/**
* @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