diff --git a/src/Symfony/Bridge/Doctrine/Tests/Fixtures/AllTypesEntity.php b/src/Symfony/Bridge/Doctrine/Tests/Fixtures/AllTypesEntity.php new file mode 100644 index 0000000000000..f47b948a70202 --- /dev/null +++ b/src/Symfony/Bridge/Doctrine/Tests/Fixtures/AllTypesEntity.php @@ -0,0 +1,159 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Doctrine\Tests\Fixtures; + +use Doctrine\DBAL\Types\Types; +use Doctrine\ORM\Mapping as ORM; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; + +#[ORM\Entity(repositoryClass: AllTypesEntityRepository::class)] +class AllTypesEntity +{ + #[ORM\Id] + #[ORM\Column(type: Types::STRING)] + public string $id; + + #[ORM\Column(type: Types::SMALLINT)] + public int $smallint; + + #[ORM\Column(type: Types::INTEGER)] + public int $integer; + + #[ORM\Column(type: Types::BIGINT, options: ['unsigned' => true])] + public string $bigint; + + #[ORM\Column(type: Types::DECIMAL, precision: 10, scale: 2)] + public string $decimal; + + #[ORM\Column(type: Types::SMALLFLOAT)] + public float $smallfloat; + + #[ORM\Column(type: Types::FLOAT)] + public float $float; + + #[ORM\Column(type: Types::STRING)] + public string $string; + + #[ORM\Column(type: Types::ASCII_STRING)] + public string $asciiString; + + #[ORM\Column(type: Types::TEXT)] + public string $text; + + #[ORM\Column(type: Types::GUID)] + public string $guid; + + #[ORM\Column(type: Types::ENUM)] + public EntityEnum $enum; + + #[ORM\Column(type: Types::BINARY)] + public mixed $binaryAsResource; + + #[ORM\Column(type: Types::BLOB)] + public mixed $blobAsResource; + + #[ORM\Column(type: Types::BOOLEAN)] + public bool $boolean; + + #[ORM\Column(type: Types::DATE_MUTABLE)] + public \DateTime $date; + + #[ORM\Column(type: Types::DATE_IMMUTABLE)] + public \DateTimeImmutable $dateImmutable; + + #[ORM\Column(type: Types::DATETIME_MUTABLE)] + public \DateTime $datetime; + + #[ORM\Column(type: Types::DATETIME_IMMUTABLE)] + public \DateTimeImmutable $datetimeImmutable; + + #[ORM\Column(type: Types::DATETIMETZ_MUTABLE)] + public \DateTime $datetimetz; + + #[ORM\Column(type: Types::DATETIMETZ_IMMUTABLE)] + public \DateTimeImmutable $datetimetzImmutable; + + #[ORM\Column(type: Types::TIME_MUTABLE)] + public \DateTime $time; + + #[ORM\Column(type: Types::TIME_IMMUTABLE)] + public \DateTimeImmutable $timeImmutable; + + #[ORM\Column(type: Types::DATEINTERVAL)] + public \DateInterval $dateinterval; + + #[ORM\ManyToOne(targetEntity: RelatedEntity::class)] + #[ORM\JoinColumn(name: 'many_to_one_id', referencedColumnName: 'id')] + public RelatedEntity $manyToOne; + + #[ORM\OneToOne(targetEntity: RelatedEntity::class)] + #[ORM\JoinColumn(name: 'one_to_one_id', referencedColumnName: 'id')] + public RelatedEntity $oneToOne; + + #[ORM\Column(type: Types::SIMPLE_ARRAY)] + public array $simpleArray; + + #[ORM\Column(type: Types::JSON, nullable: true)] + public mixed $json = []; + + #[ORM\OneToMany(targetEntity: RelatedEntity::class, mappedBy: 'allEntity')] + public Collection $oneToMany; + + #[ORM\ManyToMany(targetEntity: RelatedEntity::class)] + #[ORM\JoinTable(name: 'allentity_related')] + public Collection $manyToMany; + + public function __construct(RelatedEntity $related) + { + $this->id = '1'; + $this->smallint = 1; + $this->integer = 42; + $this->bigint = PHP_INT_MAX; + $this->decimal = '1234.56'; + $this->smallfloat = 1.23; + $this->float = 3.1415; + $this->string = 'Iñtërńâtiônàlizætiøn'; + $this->asciiString = 'ASCII text'; + $this->text = 'This is a large text block.'; + $this->guid = '4bb4f8f5-ea8d-4000-abf7-bb5b07dc2322'; + $this->enum = EntityEnum::VALUE; + + $this->binaryAsResource = fopen('php://memory', 'r+'); + fwrite($this->binaryAsResource, 'binary data'); + rewind($this->binaryAsResource); + + $this->blobAsResource = fopen('php://memory', 'r+'); + fwrite($this->blobAsResource, 'blob data'); + rewind($this->blobAsResource); + + $this->boolean = true; + $this->date = new \DateTime('2025-01-01'); + $this->dateImmutable = new \DateTimeImmutable('2025-01-01'); + $this->datetime = new \DateTime('2025-01-01 12:34:56'); + $this->datetimeImmutable = new \DateTimeImmutable('2025-01-01 12:34:56'); + $this->datetimetz = new \DateTime('2025-01-01 12:00:00', new \DateTimeZone('UTC')); + $this->datetimetzImmutable = new \DateTimeImmutable('2025-01-01 12:34:56', new \DateTimeZone('UTC')); + $this->time = new \DateTime('12:34:56'); + $this->timeImmutable = new \DateTimeImmutable('12:34:56'); + $this->dateinterval = new \DateInterval('P2D'); + + $this->manyToOne = $related; + $this->oneToOne = $related; + + $this->simpleArray = ['foo', 'bar']; + $this->json = ['key' => 'value', 'list' => [1, 2, 3]]; + + $this->oneToMany = new ArrayCollection([$related]); + $this->manyToMany = new ArrayCollection([$related]); + } +} diff --git a/src/Symfony/Bridge/Doctrine/Tests/Fixtures/AllTypesEntityRepository.php b/src/Symfony/Bridge/Doctrine/Tests/Fixtures/AllTypesEntityRepository.php new file mode 100644 index 0000000000000..b1383bf93998f --- /dev/null +++ b/src/Symfony/Bridge/Doctrine/Tests/Fixtures/AllTypesEntityRepository.php @@ -0,0 +1,24 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Doctrine\Tests\Fixtures; + +use Doctrine\ORM\EntityRepository; + +class AllTypesEntityRepository extends EntityRepository +{ + public ?AllTypesEntity $result = null; + + public function findByCustom(): ?AllTypesEntity + { + return $this->result; + } +} diff --git a/src/Symfony/Bridge/Doctrine/Tests/Fixtures/EntityEnum.php b/src/Symfony/Bridge/Doctrine/Tests/Fixtures/EntityEnum.php new file mode 100644 index 0000000000000..8ba4b07a60750 --- /dev/null +++ b/src/Symfony/Bridge/Doctrine/Tests/Fixtures/EntityEnum.php @@ -0,0 +1,17 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Doctrine\Tests\Fixtures; + +enum EntityEnum: string +{ + case VALUE = 'value'; +} diff --git a/src/Symfony/Bridge/Doctrine/Tests/Fixtures/RelatedEntity.php b/src/Symfony/Bridge/Doctrine/Tests/Fixtures/RelatedEntity.php new file mode 100644 index 0000000000000..12f3334c3dabb --- /dev/null +++ b/src/Symfony/Bridge/Doctrine/Tests/Fixtures/RelatedEntity.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Doctrine\Tests\Fixtures; + +use Doctrine\ORM\Mapping as ORM; + +#[ORM\Entity] +class RelatedEntity +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column(type: 'integer')] + public int $id; + + #[ORM\Column(type: 'string', length: 255)] + public string $name = 'Related'; +} diff --git a/src/Symfony/Bridge/Doctrine/Tests/Validator/Constraints/UniqueEntityValidatorTest.php b/src/Symfony/Bridge/Doctrine/Tests/Validator/Constraints/UniqueEntityValidatorTest.php index 4f93768cddf7c..a0a0045097024 100644 --- a/src/Symfony/Bridge/Doctrine/Tests/Validator/Constraints/UniqueEntityValidatorTest.php +++ b/src/Symfony/Bridge/Doctrine/Tests/Validator/Constraints/UniqueEntityValidatorTest.php @@ -19,6 +19,7 @@ use Doctrine\Persistence\ObjectManager; use PHPUnit\Framework\MockObject\MockObject; use Symfony\Bridge\Doctrine\Tests\DoctrineTestHelper; +use Symfony\Bridge\Doctrine\Tests\Fixtures\AllTypesEntity; use Symfony\Bridge\Doctrine\Tests\Fixtures\AssociatedEntityDto; use Symfony\Bridge\Doctrine\Tests\Fixtures\AssociationEntity; use Symfony\Bridge\Doctrine\Tests\Fixtures\AssociationEntity2; @@ -31,6 +32,7 @@ use Symfony\Bridge\Doctrine\Tests\Fixtures\Employee; use Symfony\Bridge\Doctrine\Tests\Fixtures\HireAnEmployee; use Symfony\Bridge\Doctrine\Tests\Fixtures\Person; +use Symfony\Bridge\Doctrine\Tests\Fixtures\RelatedEntity; use Symfony\Bridge\Doctrine\Tests\Fixtures\SingleIntIdEntity; use Symfony\Bridge\Doctrine\Tests\Fixtures\SingleIntIdNoToStringEntity; use Symfony\Bridge\Doctrine\Tests\Fixtures\SingleIntIdStringWrapperNameEntity; @@ -120,6 +122,8 @@ private function createSchema($em) $em->getClassMetadata(CompositeObjectNoToStringIdEntity::class), $em->getClassMetadata(SingleIntIdStringWrapperNameEntity::class), $em->getClassMetadata(UserUuidNameEntity::class), + $em->getClassMetadata(AllTypesEntity::class), + $em->getClassMetadata(RelatedEntity::class), ]); } @@ -1448,4 +1452,199 @@ public function testUuidIdentifierWithSameValueDifferentInstanceDoesNotCauseViol $this->assertNoViolation(); } + + /** + * @dataProvider provideFieldTypes + */ + public function testValidateTypesUniqueness(string $field) + { + $related = new RelatedEntity(); + $this->em->persist($related); + $entity = new AllTypesEntity($related); + $this->em->persist($entity); + $this->em->flush(); + + $entity2 = new AllTypesEntity($related); + + $constraint = new UniqueEntity( + fields: $field, + em: self::EM_NAME, + entityClass: AllTypesEntity::class, + ); + + $this->validator->validate($entity2, $constraint); + + $this->assertCount(1, $this->context->getViolations()); + } + + /** + * @dataProvider provideFieldTypes + * + * @group legacy + */ + public function testValidateTypesUniquenessDoctrineStyle(string $field) + { + $related = new RelatedEntity(); + $this->em->persist($related); + $entity = new AllTypesEntity($related); + $this->em->persist($entity); + $this->em->flush(); + + $entity2 = new AllTypesEntity($related); + + $constraint = new UniqueEntity([ + 'fields' => [$field], + 'em' => self::EM_NAME, + 'entityClass' => AllTypesEntity::class, + ]); + + $this->validator->validate($entity2, $constraint); + + $this->assertCount(1, $this->context->getViolations()); + } + + public static function provideFieldTypes(): \Generator + { + yield ['smallint']; + yield ['integer']; + yield ['bigint']; + yield ['decimal']; + yield ['smallfloat']; + yield ['float']; + yield ['string']; + yield ['asciiString']; + yield ['text']; + yield ['guid']; + yield ['enum']; + yield ['binaryAsResource']; + yield ['blobAsResource']; + yield ['boolean']; + yield ['date']; + yield ['dateImmutable']; + yield ['datetime']; + yield ['datetimeImmutable']; + yield ['datetimetz']; + yield ['datetimetzImmutable']; + yield ['time']; + yield ['timeImmutable']; + yield ['dateinterval']; + yield ['manyToOne']; + yield ['oneToOne']; + } + + /** + * @dataProvider provideUnsupportedByFindByMethodFieldTypesAndAssociations + */ + public function testUnsupportedTypesWithoutCustomMethodThrowException(string $field, string $error) + { + $this->expectException(ConstraintDefinitionException::class); + $this->expectExceptionMessage($error); + + $related = new RelatedEntity(); + $entity = new AllTypesEntity($related); + + $constraint = new UniqueEntity( + fields: $field, + em: self::EM_NAME, + entityClass: AllTypesEntity::class, + ); + + $this->validator->validate($entity, $constraint); + } + + /** + * @dataProvider provideUnsupportedByFindByMethodFieldTypesAndAssociations + * + * @group legacy + */ + public function testUnsupportedTypesWithoutCustomMethodThrowExceptionDoctrineStyle(string $field, string $error) + { + $this->expectException(ConstraintDefinitionException::class); + $this->expectExceptionMessage($error); + + $related = new RelatedEntity(); + $entity = new AllTypesEntity($related); + + $constraint = new UniqueEntity([ + 'fields' => [$field], + 'em' => self::EM_NAME, + 'entityClass' => AllTypesEntity::class, + ]); + + $this->validator->validate($entity, $constraint); + } + + /** + * @dataProvider provideUnsupportedByFindByMethodFieldTypesAndAssociations + */ + public function testUnsupportedByFindByMethodTypesAndAssociationsDoesNotCauseViolation(string $field) + { + $related = new RelatedEntity(); + $this->em->persist($related); + $entity1 = new AllTypesEntity($related); + $this->em->persist($entity1); + $this->em->flush(); + $this->em->getRepository(AllTypesEntity::class)->result = $entity1; + + $entity2 = new AllTypesEntity($related); + + $constraint = new UniqueEntity( + fields: $field, + em: self::EM_NAME, + entityClass: AllTypesEntity::class, + repositoryMethod: 'findByCustom', + ); + + $this->validator->validate($entity2, $constraint); + + $this->assertCount(1, $this->context->getViolations()); + } + + /** + * @dataProvider provideUnsupportedByFindByMethodFieldTypesAndAssociations + * + * @group legacy + */ + public function testUnsupportedByFindByMethodTypesAndAssociationsDoesNotCauseViolationDoctrineStyle(string $field) + { + $related = new RelatedEntity(); + $this->em->persist($related); + $entity1 = new AllTypesEntity($related); + $this->em->persist($entity1); + $this->em->flush(); + $this->em->getRepository(AllTypesEntity::class)->result = $entity1; + + $entity2 = new AllTypesEntity($related); + + $constraint = new UniqueEntity([ + 'fields' => [$field], + 'em' => self::EM_NAME, + 'entityClass' => AllTypesEntity::class, + 'repositoryMethod' => 'findByCustom', + ]); + + $this->validator->validate($entity2, $constraint); + + $this->assertCount(1, $this->context->getViolations()); + } + + public static function provideUnsupportedByFindByMethodFieldTypesAndAssociations(): \Generator + { + yield [ + 'simpleArray', + 'The field "simpleArray" has a Doctrine type ("simple_array") that is not supported by the findBy method. You must define a custom repository method using the "repositoryMethod" option.', + ]; + yield [ + 'json', + 'The field "json" has a Doctrine type ("json") that is not supported by the findBy method. You must define a custom repository method using the "repositoryMethod" option.', + ]; + yield [ + 'oneToMany', + 'The field "oneToMany" is a Doctrine association of type "ONE_TO_MANY", which is not supported by the findBy method. You must define a custom repository method using the "repositoryMethod" option.', + ]; + yield [ + 'manyToMany', + 'The field "manyToMany" is a Doctrine association of type "MANY_TO_MANY", which is not supported by the findBy method. You must define a custom repository method using the "repositoryMethod" option.', + ]; + } } diff --git a/src/Symfony/Bridge/Doctrine/Validator/Constraints/UniqueEntityValidator.php b/src/Symfony/Bridge/Doctrine/Validator/Constraints/UniqueEntityValidator.php index eb2e89b94dfb8..dcea1468e0495 100644 --- a/src/Symfony/Bridge/Doctrine/Validator/Constraints/UniqueEntityValidator.php +++ b/src/Symfony/Bridge/Doctrine/Validator/Constraints/UniqueEntityValidator.php @@ -11,6 +11,8 @@ namespace Symfony\Bridge\Doctrine\Validator\Constraints; +use Doctrine\DBAL\Types\Types; +use Doctrine\ORM\Mapping\ClassMetadata as MappingClassMetadata; use Doctrine\ORM\Mapping\MappingException as ORMMappingException; use Doctrine\Persistence\ManagerRegistry; use Doctrine\Persistence\Mapping\ClassMetadata; @@ -106,6 +108,14 @@ public function validate(mixed $value, Constraint $constraint): void $criteria[$fieldName] = $fieldValue; if (\is_object($criteria[$fieldName]) && $class->hasAssociation($fieldName)) { + $associationMapping = [MappingClassMetadata::MANY_TO_MANY => 'MANY_TO_MANY', MappingClassMetadata::ONE_TO_MANY => 'ONE_TO_MANY']; + if (\in_array($class->getAssociationMapping($fieldName)['type'], [MappingClassMetadata::MANY_TO_MANY, MappingClassMetadata::ONE_TO_MANY], true)) { + if ('findBy' === $constraint->repositoryMethod) { + throw new ConstraintDefinitionException(\sprintf('The field "%s" is a Doctrine association of type "%s", which is not supported by the findBy method. You must define a custom repository method using the "repositoryMethod" option.', $fieldName, $associationMapping[$class->getAssociationMapping($fieldName)['type']])); + } else { + continue; + } + } /* Ensure the Proxy is initialized before using reflection to * read its identifiers. This is necessary because the wrapped * getter methods in the Proxy are being bypassed. @@ -149,6 +159,12 @@ public function validate(mixed $value, Constraint $constraint): void * - One entity returned the uniqueness depends on the current entity. */ if ('findBy' === $constraint->repositoryMethod) { + $metadata = $em->getClassMetadata($entityClass); + foreach ($criteria as $fieldName => $mapping) { + if (\in_array($metadata->getTypeOfField($fieldName), [Types::SIMPLE_ARRAY, Types::JSON], true)) { + throw new ConstraintDefinitionException(\sprintf('The field "%s" has a Doctrine type ("%s") that is not supported by the findBy method. You must define a custom repository method using the "repositoryMethod" option.', $fieldName, $metadata->getTypeOfField($fieldName))); + } + } $arguments = [$criteria, null, 2]; }