diff --git a/src/Doctrine/Orm/Filter/DateFilter.php b/src/Doctrine/Orm/Filter/DateFilter.php index 1632645eab..4523af6ff4 100644 --- a/src/Doctrine/Orm/Filter/DateFilter.php +++ b/src/Doctrine/Orm/Filter/DateFilter.php @@ -146,13 +146,16 @@ final class DateFilter implements DateFilterInterface, FilterInterface, JsonSche use NameConverterAwareTrait; use PropertyAwareFilterTrait; + // Use literal UTC type names for compatibility with Doctrine DBAL versions before 4.5. public const DOCTRINE_DATE_TYPES = [ Types::DATE_MUTABLE => true, Types::DATETIME_MUTABLE => true, + 'datetime_utc' => true, Types::DATETIMETZ_MUTABLE => true, Types::TIME_MUTABLE => true, Types::DATE_IMMUTABLE => true, Types::DATETIME_IMMUTABLE => true, + 'datetime_utc_immutable' => true, Types::DATETIMETZ_IMMUTABLE => true, Types::TIME_IMMUTABLE => true, ]; diff --git a/src/Doctrine/Orm/Filter/SearchFilter.php b/src/Doctrine/Orm/Filter/SearchFilter.php index 398d76c82d..6ac5a9b9e8 100644 --- a/src/Doctrine/Orm/Filter/SearchFilter.php +++ b/src/Doctrine/Orm/Filter/SearchFilter.php @@ -374,10 +374,11 @@ protected function getType(string $doctrineType): string return 'array'; } + // Use literal UTC type names for compatibility with Doctrine DBAL versions before 4.5. return match ($doctrineType) { Types::BIGINT, Types::INTEGER, Types::SMALLINT => 'int', Types::BOOLEAN => 'bool', - Types::DATE_MUTABLE, Types::TIME_MUTABLE, Types::DATETIME_MUTABLE, Types::DATETIMETZ_MUTABLE, Types::DATE_IMMUTABLE, Types::TIME_IMMUTABLE, Types::DATETIME_IMMUTABLE, Types::DATETIMETZ_IMMUTABLE => \DateTimeInterface::class, + Types::DATE_MUTABLE, Types::TIME_MUTABLE, Types::DATETIME_MUTABLE, Types::DATETIMETZ_MUTABLE, Types::DATE_IMMUTABLE, Types::TIME_IMMUTABLE, Types::DATETIME_IMMUTABLE, Types::DATETIMETZ_IMMUTABLE, 'datetime_utc', 'datetime_utc_immutable' => \DateTimeInterface::class, Types::FLOAT => 'float', default => 'string', }; diff --git a/src/Doctrine/Orm/Tests/Filter/DateFilterTest.php b/src/Doctrine/Orm/Tests/Filter/DateFilterTest.php index cd66dd13f1..b249776189 100644 --- a/src/Doctrine/Orm/Tests/Filter/DateFilterTest.php +++ b/src/Doctrine/Orm/Tests/Filter/DateFilterTest.php @@ -18,7 +18,11 @@ use ApiPlatform\Doctrine\Orm\Tests\Fixtures\Entity\Dummy; use ApiPlatform\Doctrine\Orm\Tests\Fixtures\Entity\DummyDate; use ApiPlatform\Doctrine\Orm\Tests\Fixtures\Entity\DummyImmutableDate; +use ApiPlatform\Doctrine\Orm\Tests\Fixtures\Entity\DummyUtcDate; use ApiPlatform\Doctrine\Orm\Util\QueryNameGenerator; +use Doctrine\DBAL\Types\Type; +use Doctrine\ORM\EntityManagerInterface; +use PHPUnit\Framework\Attributes\DataProvider; /** * @author Théo FIDRY @@ -56,6 +60,71 @@ public function testApplyDateImmutable(): void $this->assertInstanceOf(\DateTimeImmutable::class, $queryBuilder->getParameters()[0]->getValue()); } + #[DataProvider('provideUtcDateTypes')] + public function testApplyUtcDate(string $property, string $type): void + { + $filter = new DateFilter($this->managerRegistry, null, [$property => null]); + $value = '2026-10-21T14:30:00+02:00'; + $dateClass = str_contains($type, '_immutable') ? \DateTimeImmutable::class : \DateTime::class; + + foreach (['before' => '<=', 'strictly_before' => '<', 'after' => '>=', 'strictly_after' => '>'] as $operator => $comparison) { + $queryBuilder = $this->managerRegistry->getManagerForClass(DummyUtcDate::class)->getRepository(DummyUtcDate::class)->createQueryBuilder('o'); + $filter->apply($queryBuilder, new QueryNameGenerator(), DummyUtcDate::class, null, ['filters' => [$property => [$operator => $value]]]); + + $this->assertSame(\sprintf('SELECT o FROM %s o WHERE o.%s %s :%s_p1', DummyUtcDate::class, $property, $comparison, $property), $queryBuilder->getDQL()); + $parameter = $queryBuilder->getParameter($property.'_p1'); + $this->assertNotNull($parameter); + $this->assertSame($type, $parameter->getType()); + $this->assertInstanceOf($dateClass, $parameter->getValue()); + $this->assertSame($value, $parameter->getValue()->format(\DateTimeInterface::ATOM)); + } + } + + #[DataProvider('provideUtcDateTypes')] + public function testApplyUtcDateNormalizesDatabaseValue(string $property, string $type): void + { + if (!Type::hasType($type)) { + $this->markTestSkipped('UTC datetime types require Doctrine DBAL 4.5 or later.'); + } + + $manager = $this->managerRegistry->getManagerForClass(DummyUtcDate::class); + $this->assertInstanceOf(EntityManagerInterface::class, $manager); + $queryBuilder = $manager->getRepository(DummyUtcDate::class)->createQueryBuilder('o'); + $filter = new DateFilter($this->managerRegistry, null, [$property => null]); + $filter->apply($queryBuilder, new QueryNameGenerator(), DummyUtcDate::class, null, ['filters' => [$property => ['after' => '2026-10-21T14:30:00+02:00']]]); + + $parameter = $queryBuilder->getParameter($property.'_p1'); + $this->assertNotNull($parameter); + $this->assertSame('2026-10-21 12:30:00', Type::getType($parameter->getType())->convertToDatabaseValue($parameter->getValue(), $manager->getConnection()->getDatabasePlatform())); + $this->assertSame('2026-10-21T14:30:00+02:00', $parameter->getValue()->format(\DateTimeInterface::ATOM)); + } + + #[DataProvider('provideUtcDateTypes')] + public function testGetDescriptionUtcDate(string $property, string $type): void + { + foreach ([null, [$property => null]] as $properties) { + $filter = new DateFilter($this->managerRegistry, null, $properties); + $description = $filter->getDescription(DummyUtcDate::class); + + $this->assertCount(null === $properties ? 8 : 4, $description); + foreach (['before', 'strictly_before', 'after', 'strictly_after'] as $operator) { + $this->assertSame([ + 'property' => $property, + 'type' => \DateTimeInterface::class, + 'required' => false, + ], $description[$property.'['.$operator.']']); + } + } + } + + public static function provideUtcDateTypes(): array + { + return [ + 'mutable' => ['date', 'datetime_utc'], + 'immutable' => ['immutableDate', 'datetime_utc_immutable'], + ]; + } + public static function provideApplyTestData(): array { return array_merge_recursive( diff --git a/src/Doctrine/Orm/Tests/Filter/SearchFilterTest.php b/src/Doctrine/Orm/Tests/Filter/SearchFilterTest.php index 68727660d9..41ae3e5776 100644 --- a/src/Doctrine/Orm/Tests/Filter/SearchFilterTest.php +++ b/src/Doctrine/Orm/Tests/Filter/SearchFilterTest.php @@ -17,6 +17,7 @@ use ApiPlatform\Doctrine\Orm\Tests\DoctrineOrmFilterTestCase; use ApiPlatform\Doctrine\Orm\Tests\Fixtures\CustomConverter; use ApiPlatform\Doctrine\Orm\Tests\Fixtures\Entity\Dummy; +use ApiPlatform\Doctrine\Orm\Tests\Fixtures\Entity\DummyUtcDate; use ApiPlatform\Doctrine\Orm\Tests\Fixtures\Entity\RelatedDummy; use ApiPlatform\Doctrine\Orm\Util\QueryNameGenerator; use ApiPlatform\Metadata\Exception\InvalidArgumentException; @@ -38,6 +39,25 @@ class SearchFilterTest extends DoctrineOrmFilterTestCase protected const ALIAS = 'oo'; protected string $filterClass = SearchFilter::class; + public function testGetDescriptionUtcDate(): void + { + foreach (['date', 'immutableDate'] as $property) { + $filter = self::buildSearchFilter($this, $this->managerRegistry, [$property => 'exact']); + $description = $filter->getDescription(DummyUtcDate::class); + + $this->assertCount(2, $description); + foreach ([$property => false, $property.'[]' => true] as $key => $isCollection) { + $this->assertSame([ + 'property' => $property, + 'type' => \DateTimeInterface::class, + 'required' => false, + 'strategy' => 'exact', + 'is_collection' => $isCollection, + ], $description[$key]); + } + } + } + public function testGetDescriptionDefaultFields(): void { $filter = self::buildSearchFilter($this, $this->managerRegistry); diff --git a/src/Doctrine/Orm/Tests/Fixtures/Entity/DummyUtcDate.php b/src/Doctrine/Orm/Tests/Fixtures/Entity/DummyUtcDate.php new file mode 100644 index 0000000000..cb06ea0449 --- /dev/null +++ b/src/Doctrine/Orm/Tests/Fixtures/Entity/DummyUtcDate.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Doctrine\Orm\Tests\Fixtures\Entity; + +use Doctrine\ORM\Mapping as ORM; + +#[ORM\Entity] +class DummyUtcDate +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column(type: 'integer')] + public ?int $id = null; + + #[ORM\Column(type: 'datetime_utc')] + public ?\DateTime $date = null; + + #[ORM\Column(type: 'datetime_utc_immutable')] + public ?\DateTimeImmutable $immutableDate = null; +}