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]; }
Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.
Alternative Proxies: