Custom constraint validator doesn't allow dependency injection - symfony

My constraint:
<?php
declare(strict_types=1);
namespace App\Infrastructure\Domain\Model\Company;
use Symfony\Component\Validator\Constraint;
#[\Attribute]
class ContainsUnsupportedSymbols extends Constraint
{
public string $message = 'The phone "{string}" contains an illegal characters. It can only contain numbers and/or + sign.';
public string $mode;
}
And my validator:
<?php
namespace App\Infrastructure\Domain\Model\Company;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Exception\UnexpectedValueException;
use Symfony\Contracts\Translation\TranslatorInterface;
class ContainsUnsupportedSymbolsValidator extends ConstraintValidator
{
private TranslatorInterface $translator;
public function __construct(
TranslatorInterface $translator
)
{
}
public function validate($value, Constraint $constraint)
{
if (!$constraint instanceof ContainsUnsupportedSymbols) {
throw new UnexpectedTypeException($constraint, ContainsUnsupportedSymbols::class);
}
// custom constraints should ignore null and empty values to allow
// other constraints (NotBlank, NotNull, etc.) to take care of that
if (null === $value || '' === $value) {
return;
}
if (!is_string($value)) {
// throw this exception if your validator cannot handle the passed type so that it can be marked as invalid
throw new UnexpectedValueException($value, 'string');
}
if (!preg_match('/^\+?[0-9]{3}-?[0-9]{6,12}$/', $value, $matches)) {
// the argument must be a string or an object implementing __toString()
$this->context->buildViolation(
$this->translator->trans($constraint->message, ['{{ string }}', $value])
)->addViolation();
}
}
}
This is where I use my constraint:
'phone' => [
new Assert\NotBlank(),
new Assert\Length(
min: 1,
max: 255,
),
new ContainsUnsupportedSymbols()
],
I have to translate the error message but error is thrown because of the constructor not passed parameter.
Error:
Too few arguments to function
App\Infrastructure\Domain\Model\Company\ContainsUnsupportedSymbolsValidator::__construct(),
0 passed in
/var/www/html/vendor/symfony/validator/ContainerConstraintValidatorFactory.php
on line 52 and exactly 1 expected

You forgot to use your constructor to set the value of $translator
Edit your __construct method:
private TranslatorInterface $translator;
public function __construct(
TranslatorInterface $translator
)
{
$this->translator = $translator;
}

Related

Symfony TypeTestCase, Error: Class "doctrine.orm.validator.unique" not found

Intention: I want to test if the validations I want are in place on the School entity, for which I want to write a test class extending TypeTestCase
Questions/problems:
I want to clear the error Error: Class "doctrine.orm.validator.unique" not found
I want to assert the error messages for each constraints of my elements. When I remove #[UniqueEntity('name')] from the model, then problem one vanishes but still the assertion self::assertCount(1, $form->getErrors()); fails. Which means $form->getErrors() does not have the validation error for the name being blank.
I am trying to write a symfony test a symfony Form type with a DB entity, with the following (stripped) definitions:
namespace App\Entity;
use App\Repository\SchoolRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: SchoolRepository::class)]
// >>>>>>> If I remove it problem 1 will be solved
#[UniqueEntity('name')]
class School implements TenantAwareInterface
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private $id;
#[Assert\NotBlank]
#[ORM\Column(type: 'string', length: 255, unique: true)]
private $name;
public function getId(): ?int
{
return $this->id;
}
public function getName(): ?string
{
return $this->name;
}
public function setName(string $name): self
{
$this->name = $name;
return $this;
}
}
And form being:
namespace App\Form;
use App\Entity\School;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class SchoolType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add('name');
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => School::class,
'required' => false
]);
}
}
The test:
namespace App\Tests\Integration\Form;
use App\Entity\School;
use App\Form\SchoolType;
use Doctrine\Persistence\ManagerRegistry;
use Mockery as m;
use Symfony\Bridge\Doctrine\Form\DoctrineOrmExtension;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Form\Extension\Validator\ValidatorExtension;
use Symfony\Component\Form\PreloadedExtension;
use Symfony\Component\Form\Test\Traits\ValidatorExtensionTrait;
use Symfony\Component\Form\Test\TypeTestCase;
use Symfony\Component\Validator\Validation;
use Symfony\Contracts\Translation\TranslatorInterface;
class SchoolTypeTest extends TypeTestCase
{
use ValidatorExtensionTrait;
protected function getExtensions(): array
{
$validator = Validation::createValidatorBuilder()
->enableAnnotationMapping()
->addDefaultDoctrineAnnotationReader()
->getValidator();
$mockedManagerRegistry = m::mock(ManagerRegistry::class, ['getManagers' => []]);
return [
new ValidatorExtension($validator),
new DoctrineOrmExtension($mockedManagerRegistry),
];
}
public function testValidationReturnsError()
{
$school = new School();
$form = $this->factory->create(SchoolType::class, $school);
$form->submit([]);
self::assertTrue($form->isSynchronized());
self::assertFalse($form->isValid());
// >>>>>>> I want this to assert, problem 2
self::assertCount(1, $form->getErrors());
}
}
A more simple solution :
namespace App\Tests\Service;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntityValidator;
use Symfony\Component\Form\Extension\Validator\ValidatorExtension;
use Symfony\Component\Form\FormView;
use Symfony\Component\Form\Test\Traits\ValidatorExtensionTrait;
use Symfony\Component\Form\Test\TypeTestCase;
use Symfony\Component\Validator\Validation;
class AppTypeWithValidationTestCase extends TypeTestCase
{
use ValidatorExtensionTrait;
protected function getExtensions(): array
{
$factory = new AppConstraintValidatorFactory();
$factory->addValidator(
'doctrine.orm.validator.unique',
$this->createMock(UniqueEntityValidator::class))
);
$validator = Validation::createValidatorBuilder()
->setConstraintValidatorFactory($factory)
->enableAnnotationMapping()
->addDefaultDoctrineAnnotationReader()
->getValidator();
return [
new ValidatorExtension($validator),
];
}
// *** Following is a helper function which ease the way to
// *** assert validation error messages
public static function assertFormViewHasError(FormView $formElement, string $message): void
{
foreach ($formElement->vars['errors'] as $error) {
self::assertSame($message, $error->getMessage());
}
}
}
In short, I ended up writing adding a mocked UniqueEntity validator. I added some generic codes to ease testing other form types, which are as following:
A base for tests:
namespace App\Tests\Service;
use Doctrine\Persistence\ManagerRegistry;
use Mockery as m;
use Symfony\Bridge\Doctrine\Form\DoctrineOrmExtension;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntityValidator;
use Symfony\Component\Form\Extension\Validator\ValidatorExtension;
use Symfony\Component\Form\FormView;
use Symfony\Component\Form\Test\Traits\ValidatorExtensionTrait;
use Symfony\Component\Form\Test\TypeTestCase;
use Symfony\Component\Validator\Validation;
class AppTypeWithValidationTestCase extends TypeTestCase
{
use ValidatorExtensionTrait;
protected function getExtensions(): array
{
$mockedManagerRegistry = m::mock(
ManagerRegistry::class,
[
'getManagers' => []
]
);
$factory = new AppConstraintValidatorFactory();
$factory->addValidator(
'doctrine.orm.validator.unique',
m::mock(UniqueEntityValidator::class, [
'initialize' => null,
'validate' => true,
])
);
$validator = Validation::createValidatorBuilder()
->setConstraintValidatorFactory($factory)
->enableAnnotationMapping()
->addDefaultDoctrineAnnotationReader()
->getValidator();
return [
new ValidatorExtension($validator),
new DoctrineOrmExtension($mockedManagerRegistry),
];
}
// *** Following is a helper function which ease the way to
// *** assert validation error messages
public static function assertFormViewHasError(FormView $formElement, string $message): void
{
foreach ($formElement->vars['errors'] as $error) {
self::assertSame($message, $error->getMessage());
}
}
}
A constraint validator which accepts a validator, it is needed so we can add the (mocked) definition of UniqeEntity:
namespace App\Tests\Service;
use Symfony\Component\Validator\ConstraintValidatorFactory;
use Symfony\Component\Validator\ConstraintValidatorInterface;
class AppConstraintValidatorFactory extends ConstraintValidatorFactory
{
public function addValidator(string $className, ConstraintValidatorInterface $validator): void
{
$this->validators[$className] = $validator;
}
}
And the final unit test class:
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Form;
use App\Entity\School;
use App\Form\SchoolType;
use App\Tests\Service\AppTypeWithValidationTestCase;
class SchoolTypeTest extends AppTypeWithValidationTestCase
{
public function testValidationReturnsError() {
$input = [
// *** Note that 'name' is missing here
'is_enabled' => true,
];
$school = new School();
$form = $this->factory->create(SchoolType::class, $school);
$form->submit($input);
self::assertTrue($form->isSynchronized());
self::assertFalse($form->isValid());
$view = $form->createView();
self::assertFormViewHasError($view->children['name'], 'This value should not be blank.');
}
}

Symfony EasyAdmin3: Argument 1 passed must be an instance of App\Entity

I use easyadmin for Symfony (I am a beginner), I'm stuck on this problem:
Argument 1 passed to App\Entity\MyOrder::setCarrier() must be an instance of App\Entity\Carrier or null, int given, called in /Users/My/Sites/test/src/Controller/Admin/MyOrderCrudController.php
(line in code: $myorder->setCarrier(2);)
I have this problem for all field with an relation.
however, My Entity:
/**
* #ORM\ManyToOne(targetEntity=Delivery::class, inversedBy="myOrder")
*/
private $delivery;
...
public function getCarrier(): ?carrier
{
return $this->carrier;
}
public function setCarrier(?carrier $carrier): self
{
$this->carrier = $carrier;
return $this;
}
...
My CrudController:
namespace App\Controller\Admin;
use App\Entity\MyOrder;
use App\Entity\Carrier;
use Doctrine\ORM\EntityManagerInterface;
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
use EasyCorp\Bundle\EasyAdminBundle\Context\AdminContext;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
use EasyCorp\Bundle\EasyAdminBundle\Field\ArrayField;
use EasyCorp\Bundle\EasyAdminBundle\Field\ChoiceField;
use EasyCorp\Bundle\EasyAdminBundle\Field\IdField;
use EasyCorp\Bundle\EasyAdminBundle\Field\TextField;
use EasyCorp\Bundle\EasyAdminBundle\Field\AssociationField;
use EasyCorp\Bundle\EasyAdminBundle\Router\CrudUrlGenerator;
use EasyCorp\Bundle\EasyAdminBundle\Router\AdminUrlGenerator;
class MyOrderCrudController extends AbstractCrudController
{
private $entityManager;
private $adminUrlGenerator;
public function __construct(EntityManagerInterface $entityManager, AdminUrlGenerator $adminUrlGenerator)
{
$this->entityManager = $entityManager;
$this->adminUrlGenerator = $adminUrlGenerator;
}
public static function getEntityFqcn(): string
{
return MyOrder::class;
}
public function configureCrud(Crud $crud): Crud
{
return $crud->setDefaultSort(['id' => 'DESC']);
}
public function configureActions(Actions $actions): Actions
{
$updateDelivery = Action::new('updateDelivery', 'Delivery up', 'fas fa-truck')->linkToCrudAction('updateDelivery');
return $actions
->add('detail', $updateDelivery)
->add('index', 'detail');
}
public function updateDelivery(AdminContext $context)
{
$myorder = $context->getEntity()->getInstance();
$myorder->setCarrier(2);
$this->entityManager->flush();
$url = $this->adminUrlGenerator->setRoute('admin', [])->generateUrl();
return $this->redirect($url);
}
setCarrier only accept a Carrier object. You can't pass "2" (I suppose it's the carrier id).
Try this :
$carrier = $this->entityManager->find(Carrier::class, 2);
$myorder->setCarrier($carrier);
PS : There's a typo in your entity (a class name has the first letter in uppercase, so "Carrier" instead of "carrier")

How to hide item from collection depending on some field value?

I override (custom operation and service) the DELETE operation of my app to avoid deleting data from DB. What I do is I update a field value: isDeleted === true.
Here is my controller :
class ConferenceDeleteAction extends BaseAction
{
public function __invoke(EntityService $entityService, Conference $data)
{
$entityService->markAsDeleted($data, Conference::class);
}
...
My service :
class EntityService extends BaseService
{
public function markAsDeleted(ApiBaseEntity $data, string $className)
{
/**
* #var ApiBaseEntity $entity
*/
$entity = $this->em->getRepository($className)
->findOneBy(["id" => $data->getId()]);
if ($entity === null || $entity->getDeleted()) {
throw new NotFoundHttpException('Unable to find this resource.');
}
$entity->setDeleted(true);
if ($this->dataPersister->supports($entity)) {
$this->dataPersister->persist($entity);
} else {
throw new BadRequestHttpException('An error occurs. Please do try later.');
}
}
}
How can I hide the "deleted" items from collection on GET verb (filter them from the result so that they aren't visible) ?
Here is my operation for GET verb, I don't know how to handle this :
class ConferenceListAction extends BaseAction
{
public function __invoke(Request $request, $data)
{
return $data;
}
}
I did something; I'm not sure it's a best pratice.
Since when we do :
return $data;
in our controller, API Platform has already fetch data and fill $data with.
So I decided to add my logic before the return; like :
public function __invoke(Request $request, $data)
{
$cleanDatas = [];
/**
* #var Conference $conf
*/
foreach ($data as $conf) {
if (!$conf->getDeleted()) {
$cleanDatas[] = $conf;
}
}
return $cleanDatas;
}
So now I only have undeleted items. Feel free to let me know if there is something better.
Thanks.
Custom controllers are discouraged in the docs. You are using Doctrine ORM so you can use a Custom Doctrine ORM Extension:
// api/src/Doctrine/ConferenceCollectionExtension.php
namespace App\Doctrine;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use App\Entity\Conference;
use Doctrine\ORM\QueryBuilder;
final class CarCollectionExtension implements QueryCollectionExtensionInterface
{
public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null): void
{
if ($resourceClass != Conference::class) return;
$rootAlias = $queryBuilder->getRootAliases()[0];
$queryBuilder->andWhere("$rootAlias.isDeleted = false OR $rootAlias.isDeleted IS NULL);
}
}
This will automatically be combined with any filters, sorting and pagination of collection operations with method GET.
You can make this Extension specific to an operation by adding to the if statement something like:
|| $operationName == 'conference_list'
If you're not using the autoconfiguration, you have to register the custom extension:
# api/config/services.yaml
services:
# ...
'App\Doctrine\ConferenceCollectionExtension':
tags:
- { name: api_platform.doctrine.orm.query_extension.collection }
If you also want to add a criterium for item operations, see the docs on Extensions

Why returnValueMap() is returning NULL

Trying to mock a doctrine repository inside a test, the returnValueMap() is always returning NULL when used with the findOneBy method.
I have mocked two entities then tried to mock their repository with a given return value map. The test fails and debugging shows that the returnValueMap() is returning NULL.
Here is the class to be tested (the denormalizer)
<?php
declare(strict_types=1);
namespace App\Serializer;
use App\Entity\AdditionalService;
use App\Repository\AdditionalServiceRepository;
use Dto\AdditionalServiceCollection;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
class AdditionalServiceCollectionDenormalizer implements DenormalizerInterface
{
/** #var AdditionalServiceRepository */
private $additionalServiceRepository;
public function __construct(AdditionalServiceRepository $additionalServiceRepository)
{
$this->additionalServiceRepository = $additionalServiceRepository;
}
public function denormalize($mappedCsvRow, $class, $format = null, array $context = [])
{
$addtionalServicesCollection = new AdditionalServiceCollection();
foreach ($mappedCsvRow as $fieldName => $fieldValue) {
/** #var AdditionalService $additionalService */
$additionalService = $this->additionalServiceRepository->findOneBy(['name'=>$fieldName]);
if ($additionalService) {
$addtionalServicesCollection->add($additionalService->getId(), $fieldValue);
}
}
return $addtionalServicesCollection;
}
public function supportsDenormalization($data, $type, $format = null)
{
return $type instanceof AdditionalServiceCollection;
}
}
Here is my test class:
<?php
namespace App\Tests\Import\Config;
use App\Entity\AdditionalService;
use App\Repository\AdditionalServiceRepository;
use App\Serializer\AdditionalServiceCollectionDenormalizer;
use PHPUnit\Framework\TestCase;
use Dto\AdditionalServiceCollection;
class AddionalServiceCollectionDenormalizerTest extends TestCase
{
public function provider()
{
$expected = new AdditionalServiceCollection();
$expected->add(1, 22.1)->add(2, 3.1);
return [
[['man_1' => 22.1], $expected],
[['recycling' => 3.1], $expected],
];
}
/**
* #dataProvider provider
* #covers \App\Serializer\AdditionalServiceCollectionDenormalizer::denormalize
*/
public function testDenormalize(array $row, AdditionalServiceCollection $exptected)
{
$manOneService = $this->createMock(AdditionalService::class);
$manOneService->expects($this->any())->method('getId')->willReturn(1);
$recycling = $this->createMock(AdditionalService::class);
$recycling->expects($this->any())->method('getId')->willReturn(2);
$additionalServicesRepoMock = $this
->getMockBuilder(AdditionalServiceRepository::class)
->setMethods(['findOneBy'])
->disableOriginalConstructor()
->getMock();
$additionalServicesRepoMock
->expects($this->any())
->method('findOneBy')
->will($this->returnValueMap(
[
['name'=>['man_1'], $manOneService],
['name'=>['recycling'], $recycling],
]
));
$denormalizer = new AdditionalServiceCollectionDenormalizer($additionalServicesRepoMock);
self::assertEquals($exptected, $denormalizer->denormalize($row, AdditionalServiceCollection::class));
}
}
I had a hard time debugging the PHPUnit library, to figure out finally that it is the findOneBy() method that expects two arguments, among which the second one is optional (set to null)
The willReturnMap() method is as follows:
/**
* Stubs a method by returning a value from a map.
*/
class ReturnValueMap implements Stub
{
/**
* #var array
*/
private $valueMap;
public function __construct(array $valueMap)
{
$this->valueMap = $valueMap;
}
public function invoke(Invocation $invocation)
{
$parameterCount = \count($invocation->getParameters());
foreach ($this->valueMap as $map) {
if (!\is_array($map) || $parameterCount !== (\count($map) - 1)) {
continue;
}
$return = \array_pop($map);
if ($invocation->getParameters() === $map) {
return $return;
}
}
return;
}
I suspected the method was always returning with null because of the unmet condition $parameterCount !== (\count($map) - 1).
A breakpoint confirmed my doubts, and also revealed that $invocation->getParameters() dumps as follows:
array(2) {
[0] =>
array(1) {
'name' =>
string(5) "man_1"
}
[1] =>
NULL
}
Hence, I had to explicitely pass null as second argument.
So finally the working map had to be:
$this->additionalServicesRepoMock
->method('findOneBy')
->willReturnMap([
[['name' => 'man_1'], null, $manOneService],
[['name' => 'recycling'], null, $recyclingService],
]);
It looks like the parameter of returnValueMap() in testDenormalize() needs brackets to make it indexed array.
Here's a slightly modified version of code snippet from the PHPUnit's document:
<?php
namespace App\Tests;
use PHPUnit\Framework\TestCase;
class ReturnValueMapTest extends TestCase
{
public function testReturnValueMapWithAssociativeArray()
{
$stub = $this->createMock(SomeClass::class);
$map = [
[
'name' => ['man_1'],
'Hello'
],
];
$stub->method('doSomething')
->will($this->returnValueMap($map));
// This will fail as doSomething() returns null
$this->assertSame('Hello', $stub->doSomething(['name' => ['man_1']]));
}
public function testReturnValueMapWithIndexedArray()
{
$stub = $this->createMock(SomeClass::class);
$map = [
[
['name' => ['man_1']], // Notice the difference
'Hello'
],
];
$stub->method('doSomething')
->will($this->returnValueMap($map));
$this->assertSame('Hello', $stub->doSomething(['name' => ['man_1']]));
}
}
class SomeClass
{
public function doSomething()
{}
}

Use UniqueEntity outside of entity and without forms

I need to validate an email passed by user:
private function validate($value): bool
{
$violations = $this->validator->validate($value, [
new Assert\NotBlank(),
new Assert\Email(),
new UniqueEntity([
'entityClass' => User::class,
'fields' => 'email',
])
]);
return count($violations) === 0;
}
But UniqueEntity constraint throws an exception:
Warning: get_class() expects parameter 1 to be object, string given
Seems like ValidatorInterface::validate() method's first argument awaiting for Entity object with getEmail() method, but it looks ugly.
Is there any elegant way to validate uniqueness of field passing only scalar value to ValidatorInterface::validate() method?
Seems like there is no built-in Symfony solution to do what I want, so I created custom constraint as Jakub Matczak suggested.
UPD: This solution throws a validation error when you're sending form to edit your entity. To avoid this behavior you'll need to improve this constraint manually.
Constraint:
namespace AppBundle\Validator\Constraints;
use Symfony\Component\Validator\Constraint;
class UniqueValueInEntity extends Constraint
{
public $message = 'This value is already used.';
public $entityClass;
public $field;
public function getRequiredOptions()
{
return ['entityClass', 'field'];
}
public function getTargets()
{
return self::PROPERTY_CONSTRAINT;
}
public function validatedBy()
{
return get_class($this).'Validator';
}
}
Validator:
namespace AppBundle\Validator\Constraints;
use Doctrine\ORM\EntityManager;
use InvalidArgumentException;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
class UniqueValueInEntityValidator extends ConstraintValidator
{
/**
* #var EntityManager
*/
private $em;
public function __construct(EntityManager $em)
{
$this->em = $em;
}
public function validate($value, Constraint $constraint)
{
$entityRepository = $this->em->getRepository($constraint->entityClass);
if (!is_scalar($constraint->field)) {
throw new InvalidArgumentException('"field" parameter should be any scalar type');
}
$searchResults = $entityRepository->findBy([
$constraint->field => $value
]);
if (count($searchResults) > 0) {
$this->context->buildViolation($constraint->message)
->addViolation();
}
}
}
Service:
services:
app.validator.unique_value_in_entity:
class: AppBundle\Validator\Constraints\UniqueValueInEntityValidator
arguments: ['#doctrine.orm.entity_manager']
tags:
- { name: validator.constraint_validator }
Usage example:
private function validate($value): bool
{
$violations = $this->validator->validate($value, [
new Assert\NotBlank(),
new Assert\Email(),
new UniqueValueInEntity([
'entityClass' => User::class,
'field' => 'email',
])
]);
return count($violations) === 0;
}
For this porpose i would use #UniqueEntity(fields={"email"}) in user class annotation. Kind of this way:
/**
* #ORM\Entity()
* #ORM\Table(name="user")
* #UniqueEntity(fields={"email"})
*/

Resources