diff --git a/.github/patch-types.php b/.github/patch-types.php index 33ba6347a3ef0..05076c06e10c1 100644 --- a/.github/patch-types.php +++ b/.github/patch-types.php @@ -34,6 +34,7 @@ case false !== strpos($file, '/src/Symfony/Component/PropertyInfo/Tests/Fixtures/ParentDummy.php'): case false !== strpos($file, '/src/Symfony/Component/Runtime/Internal/ComposerPlugin.php'): case false !== strpos($file, '/src/Symfony/Component/Serializer/Tests/Normalizer/Features/ObjectOuter.php'): + case false !== strpos($file, '/src/Symfony/Component/Validator/Tests/Fixtures/NestedAttribute/Entity.php'): case false !== strpos($file, '/src/Symfony/Component/VarDumper/Tests/Fixtures/NotLoadableClass.php'): case false !== strpos($file, '/src/Symfony/Component/VarDumper/Tests/Fixtures/ReflectionIntersectionTypeFixture.php'): continue 2; diff --git a/src/Symfony/Component/Validator/Constraints/All.php b/src/Symfony/Component/Validator/Constraints/All.php index d3fe49525f6fa..5b4297647da32 100644 --- a/src/Symfony/Component/Validator/Constraints/All.php +++ b/src/Symfony/Component/Validator/Constraints/All.php @@ -17,10 +17,16 @@ * * @author Bernhard Schussek */ +#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] class All extends Composite { public $constraints = []; + public function __construct($constraints = null, array $groups = null, $payload = null) + { + parent::__construct($constraints ?? [], $groups, $payload); + } + public function getDefaultOption() { return 'constraints'; diff --git a/src/Symfony/Component/Validator/Constraints/AtLeastOneOf.php b/src/Symfony/Component/Validator/Constraints/AtLeastOneOf.php index ca726ae369102..f01ed9cf4cff9 100644 --- a/src/Symfony/Component/Validator/Constraints/AtLeastOneOf.php +++ b/src/Symfony/Component/Validator/Constraints/AtLeastOneOf.php @@ -17,6 +17,7 @@ * * @author Przemysław Bogusz */ +#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] class AtLeastOneOf extends Composite { public const AT_LEAST_ONE_OF_ERROR = 'f27e6d6c-261a-4056-b391-6673a623531c'; @@ -30,6 +31,15 @@ class AtLeastOneOf extends Composite public $messageCollection = 'Each element of this collection should satisfy its own set of constraints.'; public $includeInternalMessages = true; + public function __construct($constraints = null, array $groups = null, $payload = null, string $message = null, string $messageCollection = null, bool $includeInternalMessages = null) + { + parent::__construct($constraints ?? [], $groups, $payload); + + $this->message = $message ?? $this->message; + $this->messageCollection = $messageCollection ?? $this->messageCollection; + $this->includeInternalMessages = $includeInternalMessages ?? $this->includeInternalMessages; + } + public function getDefaultOption() { return 'constraints'; diff --git a/src/Symfony/Component/Validator/Constraints/Collection.php b/src/Symfony/Component/Validator/Constraints/Collection.php index 6007b13318a56..3f4adb5ac5286 100644 --- a/src/Symfony/Component/Validator/Constraints/Collection.php +++ b/src/Symfony/Component/Validator/Constraints/Collection.php @@ -19,6 +19,7 @@ * * @author Bernhard Schussek */ +#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] class Collection extends Composite { public const MISSING_FIELD_ERROR = '2fa2158c-2a7f-484b-98aa-975522539ff8'; @@ -38,15 +39,20 @@ class Collection extends Composite /** * {@inheritdoc} */ - public function __construct($options = null) + public function __construct($fields = null, array $groups = null, $payload = null, bool $allowExtraFields = null, bool $allowMissingFields = null, string $extraFieldsMessage = null, string $missingFieldsMessage = null) { - // no known options set? $options is the fields array - if (\is_array($options) - && !array_intersect(array_keys($options), ['groups', 'fields', 'allowExtraFields', 'allowMissingFields', 'extraFieldsMessage', 'missingFieldsMessage'])) { - $options = ['fields' => $options]; + // no known options set? $fields is the fields array + if (\is_array($fields) + && !array_intersect(array_keys($fields), ['groups', 'fields', 'allowExtraFields', 'allowMissingFields', 'extraFieldsMessage', 'missingFieldsMessage'])) { + $fields = ['fields' => $fields]; } - parent::__construct($options); + parent::__construct($fields, $groups, $payload); + + $this->allowExtraFields = $allowExtraFields ?? $this->allowExtraFields; + $this->allowMissingFields = $allowMissingFields ?? $this->allowMissingFields; + $this->extraFieldsMessage = $extraFieldsMessage ?? $this->extraFieldsMessage; + $this->missingFieldsMessage = $missingFieldsMessage ?? $this->missingFieldsMessage; } /** diff --git a/src/Symfony/Component/Validator/Constraints/Composite.php b/src/Symfony/Component/Validator/Constraints/Composite.php index b24da39d22855..dd73fd581421e 100644 --- a/src/Symfony/Component/Validator/Constraints/Composite.php +++ b/src/Symfony/Component/Validator/Constraints/Composite.php @@ -51,9 +51,9 @@ abstract class Composite extends Constraint * cached. When constraints are loaded from the cache, no more group * checks need to be done. */ - public function __construct($options = null) + public function __construct($options = null, array $groups = null, $payload = null) { - parent::__construct($options); + parent::__construct($options, $groups, $payload); $this->initializeNestedConstraints(); diff --git a/src/Symfony/Component/Validator/Constraints/Sequentially.php b/src/Symfony/Component/Validator/Constraints/Sequentially.php index 0bae6f82b7424..53a0a3b912050 100644 --- a/src/Symfony/Component/Validator/Constraints/Sequentially.php +++ b/src/Symfony/Component/Validator/Constraints/Sequentially.php @@ -20,10 +20,16 @@ * * @author Maxime Steinhausser */ +#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] class Sequentially extends Composite { public $constraints = []; + public function __construct($constraints = null, array $groups = null, $payload = null) + { + parent::__construct($constraints ?? [], $groups, $payload); + } + public function getDefaultOption() { return 'constraints'; diff --git a/src/Symfony/Component/Validator/Tests/Fixtures/Annotation/Entity.php b/src/Symfony/Component/Validator/Tests/Fixtures/Annotation/Entity.php index c818062f56af4..0e07611b0f260 100644 --- a/src/Symfony/Component/Validator/Tests/Fixtures/Annotation/Entity.php +++ b/src/Symfony/Component/Validator/Tests/Fixtures/Annotation/Entity.php @@ -29,9 +29,13 @@ class Entity extends EntityParent implements EntityInterfaceB * @Assert\All(constraints={@Assert\NotNull, @Assert\Range(min=3)}) * @Assert\Collection(fields={ * "foo" = {@Assert\NotNull, @Assert\Range(min=3)}, - * "bar" = @Assert\Range(min=5) - * }) + * "bar" = @Assert\Range(min=5), + * "baz" = @Assert\Required({@Assert\Email()}), + * "qux" = @Assert\Optional({@Assert\NotBlank()}) + * }, allowExtraFields=true) * @Assert\Choice(choices={"A", "B"}, message="Must be one of %choices%") + * @Assert\AtLeastOneOf({@Assert\NotNull, @Assert\Range(min=3)}, message="foo", includeInternalMessages=false) + * @Assert\Sequentially({@Assert\NotBlank, @Assert\Range(min=5)}) */ public $firstName; /** diff --git a/src/Symfony/Component/Validator/Tests/Fixtures/Attribute/Entity.php b/src/Symfony/Component/Validator/Tests/Fixtures/Attribute/Entity.php index c4b2a7a88370f..bb069b49e0ddf 100644 --- a/src/Symfony/Component/Validator/Tests/Fixtures/Attribute/Entity.php +++ b/src/Symfony/Component/Validator/Tests/Fixtures/Attribute/Entity.php @@ -29,9 +29,13 @@ class Entity extends EntityParent implements EntityInterfaceB * @Assert\All(constraints={@Assert\NotNull, @Assert\Range(min=3)}) * @Assert\Collection(fields={ * "foo" = {@Assert\NotNull, @Assert\Range(min=3)}, - * "bar" = @Assert\Range(min=5) - * }) + * "bar" = @Assert\Range(min=5), + * "baz" = @Assert\Required({@Assert\Email()}), + * "qux" = @Assert\Optional({@Assert\NotBlank()}) + * }, allowExtraFields=true) * @Assert\Choice(choices={"A", "B"}, message="Must be one of %choices%") + * @Assert\AtLeastOneOf({@Assert\NotNull, @Assert\Range(min=3)}, message="foo", includeInternalMessages=false) + * @Assert\Sequentially({@Assert\NotBlank, @Assert\Range(min=5)}) */ #[ Assert\NotNull, diff --git a/src/Symfony/Component/Validator/Tests/Fixtures/NestedAttribute/Entity.php b/src/Symfony/Component/Validator/Tests/Fixtures/NestedAttribute/Entity.php new file mode 100644 index 0000000000000..c55796824a800 --- /dev/null +++ b/src/Symfony/Component/Validator/Tests/Fixtures/NestedAttribute/Entity.php @@ -0,0 +1,172 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Validator\Tests\Fixtures\NestedAttribute; + +use Symfony\Component\Validator\Constraints as Assert; +use Symfony\Component\Validator\Context\ExecutionContextInterface; +use Symfony\Component\Validator\Tests\Fixtures\Attribute\EntityParent; +use Symfony\Component\Validator\Tests\Fixtures\EntityInterfaceB; +use Symfony\Component\Validator\Tests\Fixtures\CallbackClass; +use Symfony\Component\Validator\Tests\Fixtures\ConstraintA; + +#[ + ConstraintA, + Assert\GroupSequence(['Foo', 'Entity']), + Assert\Callback([CallbackClass::class, 'callback']), +] +class Entity extends EntityParent implements EntityInterfaceB +{ + #[ + Assert\NotNull, + Assert\Range(min: 3), + Assert\All([ + new Assert\NotNull(), + new Assert\Range(min: 3), + ]), + Assert\All( + constraints: [ + new Assert\NotNull(), + new Assert\Range(min: 3), + ], + ), + Assert\Collection( + fields: [ + 'foo' => [ + new Assert\NotNull(), + new Assert\Range(min: 3), + ], + 'bar' => new Assert\Range(min: 5), + 'baz' => new Assert\Required([new Assert\Email()]), + 'qux' => new Assert\Optional([new Assert\NotBlank()]), + ], + allowExtraFields: true + ), + Assert\Choice(choices: ['A', 'B'], message: 'Must be one of %choices%'), + Assert\AtLeastOneOf( + constraints: [ + new Assert\NotNull(), + new Assert\Range(min: 3), + ], + message: 'foo', + includeInternalMessages: false, + ), + Assert\Sequentially([ + new Assert\NotBlank(), + new Assert\Range(min: 5), + ]), + ] + public $firstName; + #[Assert\Valid] + public $childA; + #[Assert\Valid] + public $childB; + protected $lastName; + public $reference; + public $reference2; + private $internal; + public $data = 'Overridden data'; + public $initialized = false; + + public function __construct($internal = null) + { + $this->internal = $internal; + } + + public function getFirstName() + { + return $this->firstName; + } + + public function getInternal() + { + return $this->internal.' from getter'; + } + + public function setLastName($lastName) + { + $this->lastName = $lastName; + } + + #[Assert\NotNull] + public function getLastName() + { + return $this->lastName; + } + + public function getValid() + { + } + + #[Assert\IsTrue] + public function isValid() + { + return 'valid'; + } + + #[Assert\IsTrue] + public function hasPermissions() + { + return 'permissions'; + } + + public function getData() + { + return 'Overridden data'; + } + + #[Assert\Callback(payload: 'foo')] + public function validateMe(ExecutionContextInterface $context) + { + } + + #[Assert\Callback] + public static function validateMeStatic($object, ExecutionContextInterface $context) + { + } + + /** + * @return mixed + */ + public function getChildA() + { + return $this->childA; + } + + /** + * @param mixed $childA + */ + public function setChildA($childA) + { + $this->childA = $childA; + } + + /** + * @return mixed + */ + public function getChildB() + { + return $this->childB; + } + + /** + * @param mixed $childB + */ + public function setChildB($childB) + { + $this->childB = $childB; + } + + public function getReference() + { + return $this->reference; + } +} diff --git a/src/Symfony/Component/Validator/Tests/Fixtures/NestedAttribute/EntityParent.php b/src/Symfony/Component/Validator/Tests/Fixtures/NestedAttribute/EntityParent.php new file mode 100644 index 0000000000000..5284b15f5f08c --- /dev/null +++ b/src/Symfony/Component/Validator/Tests/Fixtures/NestedAttribute/EntityParent.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Validator\Tests\Fixtures\NestedAttribute; + +use Symfony\Component\Validator\Constraints\NotNull; +use Symfony\Component\Validator\Tests\Fixtures\EntityInterfaceA; + +class EntityParent implements EntityInterfaceA +{ + protected $firstName; + private $internal; + private $data = 'Data'; + private $child; + + #[NotNull] + protected $other; + + public function getData() + { + return 'Data'; + } + + public function getChild() + { + return $this->child; + } +} diff --git a/src/Symfony/Component/Validator/Tests/Fixtures/NestedAttribute/GroupSequenceProviderEntity.php b/src/Symfony/Component/Validator/Tests/Fixtures/NestedAttribute/GroupSequenceProviderEntity.php new file mode 100644 index 0000000000000..1a88ed11b0fc2 --- /dev/null +++ b/src/Symfony/Component/Validator/Tests/Fixtures/NestedAttribute/GroupSequenceProviderEntity.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Validator\Tests\Fixtures\NestedAttribute; + +use Symfony\Component\Validator\Constraints as Assert; +use Symfony\Component\Validator\GroupSequenceProviderInterface; + +#[Assert\GroupSequenceProvider] +class GroupSequenceProviderEntity implements GroupSequenceProviderInterface +{ + public $firstName; + public $lastName; + + protected $sequence = []; + + public function __construct($sequence) + { + $this->sequence = $sequence; + } + + public function getGroupSequence() + { + return $this->sequence; + } +} diff --git a/src/Symfony/Component/Validator/Tests/Mapping/Loader/AnnotationLoaderTest.php b/src/Symfony/Component/Validator/Tests/Mapping/Loader/AnnotationLoaderTest.php index e59bfd0e6e2d5..93638412b1263 100644 --- a/src/Symfony/Component/Validator/Tests/Mapping/Loader/AnnotationLoaderTest.php +++ b/src/Symfony/Component/Validator/Tests/Mapping/Loader/AnnotationLoaderTest.php @@ -14,12 +14,18 @@ use Doctrine\Common\Annotations\AnnotationReader; use PHPUnit\Framework\TestCase; use Symfony\Component\Validator\Constraints\All; +use Symfony\Component\Validator\Constraints\AtLeastOneOf; use Symfony\Component\Validator\Constraints\Callback; use Symfony\Component\Validator\Constraints\Choice; use Symfony\Component\Validator\Constraints\Collection; +use Symfony\Component\Validator\Constraints\Email; use Symfony\Component\Validator\Constraints\IsTrue; +use Symfony\Component\Validator\Constraints\NotBlank; use Symfony\Component\Validator\Constraints\NotNull; +use Symfony\Component\Validator\Constraints\Optional; use Symfony\Component\Validator\Constraints\Range; +use Symfony\Component\Validator\Constraints\Required; +use Symfony\Component\Validator\Constraints\Sequentially; use Symfony\Component\Validator\Constraints\Valid; use Symfony\Component\Validator\Mapping\ClassMetadata; use Symfony\Component\Validator\Mapping\Loader\AnnotationLoader; @@ -65,14 +71,24 @@ public function testLoadClassMetadata(string $namespace) $expected->addPropertyConstraint('firstName', new Range(['min' => 3])); $expected->addPropertyConstraint('firstName', new All([new NotNull(), new Range(['min' => 3])])); $expected->addPropertyConstraint('firstName', new All(['constraints' => [new NotNull(), new Range(['min' => 3])]])); - $expected->addPropertyConstraint('firstName', new Collection(['fields' => [ + $expected->addPropertyConstraint('firstName', new Collection([ 'foo' => [new NotNull(), new Range(['min' => 3])], 'bar' => new Range(['min' => 5]), - ]])); + 'baz' => new Required([new Email()]), + 'qux' => new Optional([new NotBlank()]), + ], null, null, true)); $expected->addPropertyConstraint('firstName', new Choice([ 'message' => 'Must be one of %choices%', 'choices' => ['A', 'B'], ])); + $expected->addPropertyConstraint('firstName', new AtLeastOneOf([ + new NotNull(), + new Range(['min' => 3]), + ], null, null, 'foo', null, false)); + $expected->addPropertyConstraint('firstName', new Sequentially([ + new NotBlank(), + new Range(['min' => 5]), + ])); $expected->addPropertyConstraint('childA', new Valid()); $expected->addPropertyConstraint('childB', new Valid()); $expected->addGetterConstraint('lastName', new NotNull()); @@ -141,14 +157,24 @@ public function testLoadClassMetadataAndMerge(string $namespace) $expected->addPropertyConstraint('firstName', new Range(['min' => 3])); $expected->addPropertyConstraint('firstName', new All([new NotNull(), new Range(['min' => 3])])); $expected->addPropertyConstraint('firstName', new All(['constraints' => [new NotNull(), new Range(['min' => 3])]])); - $expected->addPropertyConstraint('firstName', new Collection(['fields' => [ + $expected->addPropertyConstraint('firstName', new Collection([ 'foo' => [new NotNull(), new Range(['min' => 3])], 'bar' => new Range(['min' => 5]), - ]])); + 'baz' => new Required([new Email()]), + 'qux' => new Optional([new NotBlank()]), + ], null, null, true)); $expected->addPropertyConstraint('firstName', new Choice([ 'message' => 'Must be one of %choices%', 'choices' => ['A', 'B'], ])); + $expected->addPropertyConstraint('firstName', new AtLeastOneOf([ + new NotNull(), + new Range(['min' => 3]), + ], null, null, 'foo', null, false)); + $expected->addPropertyConstraint('firstName', new Sequentially([ + new NotBlank(), + new Range(['min' => 5]), + ])); $expected->addPropertyConstraint('childA', new Valid()); $expected->addPropertyConstraint('childB', new Valid()); $expected->addGetterConstraint('lastName', new NotNull()); @@ -185,5 +211,9 @@ public function provideNamespaces(): iterable if (\PHP_VERSION_ID >= 80000) { yield 'attributes' => ['Symfony\Component\Validator\Tests\Fixtures\Attribute']; } + + if (\PHP_VERSION_ID >= 80100) { + yield 'nested_attributes' => ['Symfony\Component\Validator\Tests\Fixtures\NestedAttribute']; + } } }