From dc335244c53f3193e44ae9453ed7afc9647321a6 Mon Sep 17 00:00:00 2001 From: Nicolas PHILIPPE Date: Sun, 2 Feb 2025 14:28:39 +0100 Subject: [PATCH] feat: validate objects --- bin/console | 8 ++- composer.json | 3 +- config/services.php | 8 +++ docs/index.rst | 32 +++++++++ src/Configuration.php | 2 + src/Object/ValidationListener.php | 30 ++++++++ src/ObjectFactory.php | 46 ++++++++++++ src/Persistence/PersistentObjectFactory.php | 2 +- src/ZenstruckFoundryBundle.php | 39 +++++++++-- tests/Fixture/Entity/EntityForValidation.php | 29 ++++++++ .../Fixture/config/validation_available.yaml | 2 + tests/Fixture/config/validation_enabled.yaml | 6 ++ .../config/validation_not_available.yaml | 3 + tests/Integration/ValidationTest.php | 70 +++++++++++++++++++ 14 files changed, 271 insertions(+), 9 deletions(-) create mode 100644 src/Object/ValidationListener.php create mode 100644 tests/Fixture/Entity/EntityForValidation.php create mode 100644 tests/Fixture/config/validation_available.yaml create mode 100644 tests/Fixture/config/validation_enabled.yaml create mode 100644 tests/Fixture/config/validation_not_available.yaml create mode 100644 tests/Integration/ValidationTest.php diff --git a/bin/console b/bin/console index 9ff51b3b3..bcabfb0e4 100755 --- a/bin/console +++ b/bin/console @@ -6,5 +6,11 @@ use Zenstruck\Foundry\Tests\Fixture\TestKernel; require_once __DIR__ . '/../tests/bootstrap.php'; -$application = new Application(new TestKernel('test', true)); +foreach ($argv ?? [] as $i => $arg) { + if (($arg === '--env' || $arg === '-e') && isset($argv[$i + 1])) { + $_ENV['APP_ENV'] = $argv[$i + 1]; + break; + } +} +$application = new Application(new TestKernel($_ENV['APP_ENV'], true)); $application->run(); diff --git a/composer.json b/composer.json index beeb7cafb..94a4bdab5 100644 --- a/composer.json +++ b/composer.json @@ -35,8 +35,8 @@ "doctrine/common": "^3.2.2", "doctrine/doctrine-bundle": "^2.10", "doctrine/doctrine-migrations-bundle": "^2.2|^3.0", - "doctrine/mongodb-odm-bundle": "^4.6|^5.0", "doctrine/mongodb-odm": "^2.4", + "doctrine/mongodb-odm-bundle": "^4.6|^5.0", "doctrine/orm": "^2.16|^3.0", "phpunit/phpunit": "^9.5.0 || ^10.0 || ^11.0", "symfony/console": "^6.4|^7.0", @@ -46,6 +46,7 @@ "symfony/runtime": "^6.4|^7.0", "symfony/translation-contracts": "^3.4", "symfony/uid": "^6.4|^7.0", + "symfony/validator": "^6.4|^7.0", "symfony/var-dumper": "^6.4|^7.0", "symfony/yaml": "^6.4|^7.0" }, diff --git a/config/services.php b/config/services.php index 94b92aa01..28d78f5dc 100644 --- a/config/services.php +++ b/config/services.php @@ -5,7 +5,9 @@ use Faker; use Zenstruck\Foundry\Configuration; use Zenstruck\Foundry\FactoryRegistry; +use Zenstruck\Foundry\Object\Event\AfterInstantiate; use Zenstruck\Foundry\Object\Instantiator; +use Zenstruck\Foundry\Object\ValidationListener; use Zenstruck\Foundry\StoryRegistry; return static function (ContainerConfigurator $container): void { @@ -33,7 +35,13 @@ service('.zenstruck_foundry.story_registry'), service('.zenstruck_foundry.persistence_manager')->nullOnInvalid(), service('event_dispatcher'), + param('.zenstruck_foundry.validation_enabled'), + abstract_arg('validation_available'), ]) ->public() + + ->set('.zenstruck_foundry.validation_listener', ValidationListener::class) + ->args([service('validator')]) + ->tag('kernel.event_listener', ['event' => AfterInstantiate::class, 'method' => '__invoke']) ; }; diff --git a/docs/index.rst b/docs/index.rst index 7921a68c4..5f42674ef 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1187,6 +1187,38 @@ You can even create associative arrays, with the nice DX provided by Foundry: // will create ['prop1' => 'foo', 'prop2' => 'default value 2'] $array = SomeArrayFactory::createOne(['prop1' => 'foo']); +Validate your objects +~~~~~~~~~~~~~~~~~~~~~ + +Foundry can validate your objects automatically after they are instantiated. This can be useful to +ensure that your objects are in a valid state before they are used in your tests. + +You can either enable validation them globally: + +.. configuration-block:: + + .. code-block:: yaml + + # config/packages/zenstruck_foundry.yaml + when@dev: # see Bundle Configuration section about sharing this in the test environment + zenstruck_foundry: + instantiator: + validate: true + +Or enable/disable it for a specific factory, or in a specific test with methods ``withValidation()`` / ``withoutValidation()``: + +:: + + class UserFactory extends ObjectFactory + { + protected function initialize(): static + { + return $this->withValidation(); + // you can also disable validation using ->withoutValidation() + } + } + + Using with DoctrineFixturesBundle --------------------------------- diff --git a/src/Configuration.php b/src/Configuration.php index 3bf880287..494238b76 100644 --- a/src/Configuration.php +++ b/src/Configuration.php @@ -53,6 +53,8 @@ public function __construct( public readonly StoryRegistry $stories, private readonly ?PersistenceManager $persistence = null, private readonly ?EventDispatcherInterface $eventDispatcher = null, + public readonly bool $validationEnabled = false, + public readonly bool $validationAvailable = false, ) { $this->instantiator = $instantiator; } diff --git a/src/Object/ValidationListener.php b/src/Object/ValidationListener.php new file mode 100644 index 000000000..38d25e69c --- /dev/null +++ b/src/Object/ValidationListener.php @@ -0,0 +1,30 @@ +factory->validationEnabled()) { + return; + } + + $violations = $this->validator->validate($event->object); + + if ($violations->count() > 0) { + throw new ValidationFailedException($event->object, $violations); + } + } +} diff --git a/src/ObjectFactory.php b/src/ObjectFactory.php index 4abf7c485..c6c80f545 100644 --- a/src/ObjectFactory.php +++ b/src/ObjectFactory.php @@ -35,6 +35,16 @@ abstract class ObjectFactory extends Factory /** @phpstan-var InstantiatorCallable|null */ private $instantiator; + private bool $validationEnabled; + + // keep an empty constructor for BC + public function __construct() + { + parent::__construct(); + + $this->validationEnabled = Configuration::instance()->validationEnabled; + } + /** * @return class-string */ @@ -105,6 +115,42 @@ public function afterInstantiate(callable $callback): static return $clone; } + /** + * @psalm-return static + * @phpstan-return static + */ + public function withValidation(): static + { + if (!Configuration::instance()->validationAvailable) { + throw new \LogicException('Validation is not available. Make sure the "symfony/validator" package is installed and validation enabled.'); + } + + $clone = clone $this; + $clone->validationEnabled = true; + + return $clone; + } + + /** + * @psalm-return static + * @phpstan-return static + */ + public function withoutValidation(): static + { + $clone = clone $this; + $clone->validationEnabled = false; + + return $clone; + } + + /** + * @internal + */ + public function validationEnabled(): bool + { + return $this->validationEnabled; + } + /** * @internal */ diff --git a/src/Persistence/PersistentObjectFactory.php b/src/Persistence/PersistentObjectFactory.php index e426da4d8..4500f39d1 100644 --- a/src/Persistence/PersistentObjectFactory.php +++ b/src/Persistence/PersistentObjectFactory.php @@ -396,7 +396,7 @@ protected function normalizeObject(object $object): object } } - final protected function isPersisting(): bool + final public function isPersisting(): bool { $config = Configuration::instance(); diff --git a/src/ZenstruckFoundryBundle.php b/src/ZenstruckFoundryBundle.php index 52a55e91f..b8da5a3a9 100644 --- a/src/ZenstruckFoundryBundle.php +++ b/src/ZenstruckFoundryBundle.php @@ -14,9 +14,11 @@ use Symfony\Component\Config\Definition\Configurator\DefinitionConfigurator; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Exception\LogicException; use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\HttpKernel\Bundle\AbstractBundle; +use Symfony\Component\Validator\Validator\ValidatorInterface; use Zenstruck\Foundry\Mongo\MongoResetter; use Zenstruck\Foundry\Object\Instantiator; use Zenstruck\Foundry\ORM\ResetDatabase\MigrateDatabaseResetter; @@ -87,6 +89,16 @@ public function configure(DefinitionConfigurator $definition): void ->example('my_instantiator') ->defaultNull() ->end() + ->arrayNode('validation') + ->info('Automatically validate the objects created.') + ->canBeEnabled() + ->validate() + ->ifTrue(function(array $validation): bool { + return $validation['enabled'] && !interface_exists(ValidatorInterface::class); + }) + ->thenInvalid('Validation support cannot be enabled as the Validator component is not installed. Try running "composer require --dev symfony/validator".') + ->end() + ->end() ->end() ->end() ->arrayNode('global_state') @@ -188,19 +200,16 @@ public function configure(DefinitionConfigurator $definition): void ->end() ->end() ->end() + ->booleanNode('enable_validation')->defaultFalse()->end() ->end() ; } public function loadExtension(array $config, ContainerConfigurator $configurator, ContainerBuilder $container): void // @phpstan-ignore missingType.iterableValue { - $container->registerForAutoconfiguration(Factory::class) - ->addTag('foundry.factory') - ; + $container->registerForAutoconfiguration(Factory::class)->addTag('foundry.factory'); - $container->registerForAutoconfiguration(Story::class) - ->addTag('foundry.story') - ; + $container->registerForAutoconfiguration(Story::class)->addTag('foundry.story'); $configurator->import('../config/services.php'); @@ -282,6 +291,8 @@ public function loadExtension(array $config, ContainerConfigurator $configurator ->replaceArgument(0, $config['mongo']['reset']['document_managers']) ; } + + $container->setParameter('.zenstruck_foundry.validation_enabled', $config['instantiator']['validation']['enabled']); } public function build(ContainerBuilder $container): void @@ -300,6 +311,22 @@ public function process(ContainerBuilder $container): void ->addMethodCall('addProvider', [new Reference($id)]) ; } + + // validation + $container->getDefinition('.zenstruck_foundry.configuration') + ->replaceArgument(7, $container->has('validator')); + + if (!interface_exists(ValidatorInterface::class)) { + $container->removeDefinition('.zenstruck_foundry.validation_listener'); + } + + if ($container->has('.zenstruck_foundry.configuration') && !$container->has('validator')) { + if ($container->getParameter('.zenstruck_foundry.validation_enabled') === true) { + throw new LogicException('Validation support cannot be enabled because the validation is not enabled. Please, add enable validation with configuration "framework.validation: true".'); + } + + $container->removeDefinition('.zenstruck_foundry.validation_listener'); + } } /** diff --git a/tests/Fixture/Entity/EntityForValidation.php b/tests/Fixture/Entity/EntityForValidation.php new file mode 100644 index 000000000..c1441d78a --- /dev/null +++ b/tests/Fixture/Entity/EntityForValidation.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Tests\Fixture\Entity; + +use Doctrine\ORM\Mapping as ORM; +use Zenstruck\Foundry\Tests\Fixture\Model\Base; +use Symfony\Component\Validator\Constraints as Assert; + +#[ORM\Entity] +class EntityForValidation extends Base +{ + public function __construct( + #[ORM\Column()] + #[Assert\NotBlank()] + public string $name = '', + ) { + } +} diff --git a/tests/Fixture/config/validation_available.yaml b/tests/Fixture/config/validation_available.yaml new file mode 100644 index 000000000..1b97059de --- /dev/null +++ b/tests/Fixture/config/validation_available.yaml @@ -0,0 +1,2 @@ +framework: + validation: true diff --git a/tests/Fixture/config/validation_enabled.yaml b/tests/Fixture/config/validation_enabled.yaml new file mode 100644 index 000000000..93ad8a538 --- /dev/null +++ b/tests/Fixture/config/validation_enabled.yaml @@ -0,0 +1,6 @@ +framework: + validation: true + +zenstruck_foundry: + instantiator: + validation: true diff --git a/tests/Fixture/config/validation_not_available.yaml b/tests/Fixture/config/validation_not_available.yaml new file mode 100644 index 000000000..fbd4e4e47 --- /dev/null +++ b/tests/Fixture/config/validation_not_available.yaml @@ -0,0 +1,3 @@ +zenstruck_foundry: + instantiator: + validation: true diff --git a/tests/Integration/ValidationTest.php b/tests/Integration/ValidationTest.php new file mode 100644 index 000000000..092ca920b --- /dev/null +++ b/tests/Integration/ValidationTest.php @@ -0,0 +1,70 @@ +withValidation()->create(); + } + + public function test_it_throws_if_validation_enabled_in_foundry_but_disabled_in_symfony(): void + { + self::expectException(\LogicException::class); + self::expectExceptionMessage('Validation support cannot be enabled'); + + self::bootKernel(['environment' => 'validation_not_available']); + + object(EntityForValidation::class); + } + + public function test_it_validates_object_if_validation_forced(): void + { + self::expectException(ValidationFailedException::class); + + self::bootKernel(['environment' => 'validation_available']); + + factory(EntityForValidation::class)->withValidation()->create(); + } + + public function test_it_validates_object_if_validation_enabled_globally(): void + { + self::expectException(ValidationFailedException::class); + + self::bootKernel(['environment' => 'validation_enabled']); + + object(EntityForValidation::class); + } + + public function test_validation_can_be_disabled(): void + { + self::expectNotToPerformAssertions(); + + self::bootKernel(['environment' => 'validation_enabled']); + + factory(EntityForValidation::class)->withoutValidation()->create(); + } +}