Repository: laminas/laminas-validator (php)

Issue:
New conditional validator
The idea is to add a new validator that executes a validator when a rule is met.

## Example

```php
$validator = new Laminas\Validator\ConditionalValidator(
    [
        'validator' => [
            'name' => Laminas\Validator\NotEmpty::class,
        ],
        'rule'      => static function (array $context) {
            return (bool) ($context['confirm'] ?? false);
        },
    ]
);
```

```php
$data = [
    'registration' => 'foo',
    'confirm'      => '1',
];

var_dump($validator->isValid($data['registration'], $data)); // true

$data = [
    'registration' => '',
    'confirm'      => '1',
];

var_dump($validator->isValid($data['registration'], $data)); // false

$data = [
    'registration' => '',
    'confirm'      => '',
];

var_dump($validator->isValid($data['registration'], $data)); // true
```

## Implementation

The validator needs the plugin manager and a solution to allow a chain of validators, for example a third option – `validators` next to `validator`:

```php
$validator = new Laminas\Validator\ConditionalValidator(
    [
        'validators' => [
            [
                'name'    => Laminas\Validator\StringLength::class,
                'options' => [
                    'min' => 3,
                    'max' => 255,
                ],
            ],
            [
                'name' => Laminas\I18n\Validator\Alnum::class,
            ],
        ],
        'rule'      => static function (array $context) {
            return (bool) ($context['confirm'] ?? false);
        },
    ]
);
```

## Reference

https://book.cakephp.org/5/en/core-libraries/validation.html#conditional-validation

Pull request description:
Introduce `Conditional` validator
|    Q          |   A
|-------------- | ------
| Documentation | yes
| New Feature   | yes

### Description

Closes #370 

Patch:
diff --git a/docs/book/v3/set.md b/docs/book/v3/set.md
index ad0e38f0..f867014c 100644
--- a/docs/book/v3/set.md
+++ b/docs/book/v3/set.md
@@ -5,6 +5,7 @@ The following validators come with the laminas-validator distribution.
 - [BackedEnumValue](validators/backed-enum-value.md)
 - [Barcode](validators/barcode.md)
 - [Callback](validators/callback.md)
+- [Conditional](validators/conditional.md)
 - [CreditCard](validators/credit-card.md)
 - [Date](validators/date.md)
 - [DateComparison](validators/date-comparison.md)
diff --git a/docs/book/v3/validators/conditional.md b/docs/book/v3/validators/conditional.md
new file mode 100644
index 00000000..7f6b52be
--- /dev/null
+++ b/docs/book/v3/validators/conditional.md
@@ -0,0 +1,68 @@
+# Conditional Validator
+
+`Laminas\Validator\Conditional` allows you to validate a value conditionally depending on the outcome of a user-defined rule.
+
+## Supported options
+
+The following options are supported for `Laminas\Validator\Conditional`:
+
+- `rule`: A callable that will determine whether validation should execute on the value or not
+- `validators`: A list of validator specifications used to create a [validator chain](../validator-chains.md)
+
+## About the `$context` parameter of validators
+
+Typically, `laminas-validator` is used via `laminas-inputfilter` which is often, in turn, used via `laminas-form`.
+Some validators accept a second parameter to the `isValid()` method that contains the entire payload in an unfiltered and un-validated state.
+This parameter `$context` is normally the entire `$_POST` payload.
+
+`laminas-inputfilter` always passes this parameter to the `isValid` method, but, because it is not part of the `ValidatorInterface` contract, it's documentation has often been overlooked.
+
+## Basic usage
+
+The Conditional validator makes use of the `$context` parameter to determine whether validation should execute on the given value.
+
+As such, the `rule` parameter must be a callable that accepts the `$context` array and returns a boolean to indicate whether validation should proceed.
+
+If the rule returns false, the validator will return true, passing validation regardless of the input value. When the rule returns true, validation proceeds and returns either a successful or unsuccessful result depending on the input.
+
+The `Conditional` validator must create validation chains from the given `validators` option, therefore it requires the `ValidatorChainFactory` as a constructor dependency.
+
+Normally, using validators via `laminas-inputfilter` or `laminas-form`, this will not be a concern, but in standalone usage as documented here, we must account for this requirement.
+
+In the following example, we only wish to validate the email address if the `subscribe` field evaluates to a truthy value:
+
+```php
+use Laminas\ServiceManager\ServiceManager;
+use Laminas\Validator\Conditional;
+use Laminas\Validator\EmailAddress;
+use Laminas\Validator\ValidatorChainFactory;
+use Laminas\Validator\ValidatorPluginManager;
+
+// The chain factory would typically be retrieved from a DI container,
+// but instantiated directly here for brevity.
+$chainFactory = new ValidatorChainFactory(
+    new ValidatorPluginManager(
+        new ServiceManager(),
+    ),
+);
+
+$validator = new Conditional($chainFactory, [
+    'rule' => static function (array $context): bool {
+        return (bool) ($context['subscribe'] ?? null) === true;
+    },
+    'validators' => [
+        ['name' => EmailAddress::class],
+    ],
+]);
+
+$postPayload = [
+    'subscribe' => '1',
+    'email' => 'kermit@example.com',
+];
+
+$validator->isValid($postPayload['email'], $postPayload); // true
+```
+
+The `rule` option can be any callable, providing it has the signature `callable(array): bool`.
+
+The `validators` option can contain as many validators as required. For more information on the shape of the `validators` option, consult the documentation on the [`ValidatorChainFactory`](../validator-chains.md#the-validator-chain-factory)
diff --git a/mkdocs.yml b/mkdocs.yml
index 41620594..c370d16c 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -13,6 +13,7 @@ nav:
             - Barcode: v3/validators/barcode.md
             - BIC: v3/validators/bic.md
             - Callback: v3/validators/callback.md
+            - Conditional: v3/validators/conditional.md
             - CreditCard: v3/validators/credit-card.md
             - Date: v3/validators/date.md
             - DateComparison: v3/validators/date-comparison.md
diff --git a/src/Conditional.php b/src/Conditional.php
new file mode 100644
index 00000000..4146e1a3
--- /dev/null
+++ b/src/Conditional.php
@@ -0,0 +1,52 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Laminas\Validator;
+
+use Closure;
+use Laminas\Validator\Exception\InvalidArgumentException;
+
+use function is_callable;
+
+/**
+ * @psalm-import-type ValidatorSpecification from ValidatorInterface
+ * @psalm-type OptionsArgument = array{
+ *     rule: callable(array<string, mixed>): bool,
+ *     validators: array<array-key, ValidatorSpecification>,
+ * }
+ */
+final class Conditional implements ValidatorInterface
+{
+    /** @var Closure(array<string, mixed>): bool */
+    private readonly Closure $rule;
+    private readonly ValidatorChain $chain;
+
+    /** @param OptionsArgument $options */
+    public function __construct(ValidatorChainFactory $chainFactory, array $options)
+    {
+        $rule = $options['rule'] ?? null;
+        if (! is_callable($rule)) {
+            throw new InvalidArgumentException('The `rule` option must be callable');
+        }
+
+        $this->rule  = ($rule)(...);
+        $this->chain = $chainFactory->fromArray($options['validators']);
+    }
+
+    /** @param array<string, mixed> $context */
+    public function isValid(mixed $value, array $context = []): bool
+    {
+        if (! ($this->rule)($context)) {
+            return true;
+        }
+
+        return $this->chain->isValid($value, $context);
+    }
+
+    /** @inheritDoc */
+    public function getMessages(): array
+    {
+        return $this->chain->getMessages();
+    }
+}
diff --git a/src/ConditionalFactory.php b/src/ConditionalFactory.php
new file mode 100644
index 00000000..b6f2b63d
--- /dev/null
+++ b/src/ConditionalFactory.php
@@ -0,0 +1,24 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Laminas\Validator;
+
+use Laminas\ServiceManager\Factory\FactoryInterface;
+use Psr\Container\ContainerInterface;
+
+final class ConditionalFactory implements FactoryInterface
+{
+    /** @inheritDoc */
+    public function __invoke(
+        ContainerInterface $container,
+        string $requestedName,
+        ?array $options = null,
+    ): Conditional {
+        /** @psalm-suppress MixedArgumentTypeCoercion - It's not worth attempting runtime validation of the options shape here */
+        return new Conditional(
+            $container->get(ValidatorChainFactory::class),
+            $options ?? [],
+        );
+    }
+}
diff --git a/src/ValidatorChainFactory.php b/src/ValidatorChainFactory.php
index fb14fe5d..81dad197 100644
--- a/src/ValidatorChainFactory.php
+++ b/src/ValidatorChainFactory.php
@@ -7,12 +7,7 @@ namespace Laminas\Validator;
 use function assert;
 
 /**
- * @psalm-type ValidatorSpecification = array{
- *     name: string|class-string<ValidatorInterface>,
- *     priority?: int,
- *     break_chain_on_failure?: bool,
- *     options?: array<string, mixed>,
- * }
+ * @psalm-import-type ValidatorSpecification from ValidatorInterface
  */
 final class ValidatorChainFactory
 {
diff --git a/src/ValidatorPluginManager.php b/src/ValidatorPluginManager.php
index fb362a7c..0102994d 100644
--- a/src/ValidatorPluginManager.php
+++ b/src/ValidatorPluginManager.php
@@ -27,6 +27,7 @@ final class ValidatorPluginManager extends AbstractSingleInstancePluginManager
             Bitwise::class                   => InvokableFactory::class,
             BusinessIdentifierCode::class    => InvokableFactory::class,
             Callback::class                  => InvokableFactory::class,
+            Conditional::class               => ConditionalFactory::class,
             CreditCard::class                => InvokableFactory::class,
             DateStep::class                  => InvokableFactory::class,
             Date::class                      => InvokableFactory::class,


Test patch:
diff --git a/test/ConditionalFactoryTest.php b/test/ConditionalFactoryTest.php
new file mode 100644
index 00000000..49aa42aa
--- /dev/null
+++ b/test/ConditionalFactoryTest.php
@@ -0,0 +1,38 @@
+<?php
+
+declare(strict_types=1);
+
+namespace LaminasTest\Validator;
+
+use Laminas\ServiceManager\ServiceManager;
+use Laminas\Validator\ConditionalFactory;
+use Laminas\Validator\NotEmpty;
+use Laminas\Validator\ValidatorChainFactory;
+use Laminas\Validator\ValidatorPluginManager;
+use LaminasTest\Validator\TestAsset\InMemoryContainer;
+use PHPUnit\Framework\TestCase;
+
+final class ConditionalFactoryTest extends TestCase
+{
+    public function testThatOptionsArePassedToTheValidator(): void
+    {
+        $chainFactory = new ValidatorChainFactory(new ValidatorPluginManager(new ServiceManager()));
+
+        $container = new InMemoryContainer();
+        $container->set(ValidatorChainFactory::class, $chainFactory);
+
+        $factory   = new ConditionalFactory();
+        $validator = $factory->__invoke($container, 'Whatever', [
+            'rule'       => static function (array $context): bool {
+                return ($context['trigger'] ?? null) === true;
+            },
+            'validators' => [
+                ['name' => NotEmpty::class],
+            ],
+        ]);
+
+        self::assertTrue($validator->isValid('Foo', ['trigger' => true]));
+        self::assertFalse($validator->isValid('', ['trigger' => true]));
+        self::assertTrue($validator->isValid('', ['trigger' => false]));
+    }
+}
diff --git a/test/ConditionalTest.php b/test/ConditionalTest.php
new file mode 100644
index 00000000..65292283
--- /dev/null
+++ b/test/ConditionalTest.php
@@ -0,0 +1,116 @@
+<?php
+
+declare(strict_types=1);
+
+namespace LaminasTest\Validator;
+
+use Laminas\ServiceManager\ServiceManager;
+use Laminas\Validator\Conditional;
+use Laminas\Validator\ConfigProvider;
+use Laminas\Validator\Digits;
+use Laminas\Validator\Exception\InvalidArgumentException;
+use Laminas\Validator\NotEmpty;
+use Laminas\Validator\ValidatorChainFactory;
+use Laminas\Validator\ValidatorPluginManager;
+use PHPUnit\Framework\TestCase;
+use Psr\Container\ContainerExceptionInterface;
+use Psr\Container\NotFoundExceptionInterface;
+
+final class ConditionalTest extends TestCase
+{
+    private ValidatorChainFactory $factory;
+
+    protected function setUp(): void
+    {
+        $this->factory = new ValidatorChainFactory(new ValidatorPluginManager(new ServiceManager()));
+    }
+
+    public function testConditionalValidationHappyPath(): void
+    {
+        $validator = new Conditional(
+            $this->factory,
+            [
+                'rule'       => static function (array $context): bool {
+                    return ($context['trigger'] ?? null) === true;
+                },
+                'validators' => [
+                    ['name' => NotEmpty::class],
+                ],
+            ],
+        );
+
+        self::assertTrue(
+            $validator->isValid(''),
+            'When the rule returns false, validation should pass for any input',
+        );
+
+        self::assertTrue(
+            $validator->isValid('Foo', ['trigger' => true]),
+            'When the rule returns true, validation should execute for the given valid value and pass',
+        );
+
+        self::assertFalse(
+            $validator->isValid('', ['trigger' => true]),
+            'When the rule returns true, validation should execute for the given invalid value and fail',
+        );
+    }
+
+    public function testMessagesFromTheValidationChainAreReturned(): void
+    {
+        $validator = new Conditional(
+            $this->factory,
+            [
+                'rule'       => static function (array $context): bool {
+                    return ($context['trigger'] ?? null) === true;
+                },
+                'validators' => [
+                    ['name' => Digits::class],
+                ],
+            ],
+        );
+
+        self::assertFalse($validator->isValid('Foo', ['trigger' => true]));
+        $messages = $validator->getMessages();
+        self::assertCount(1, $messages);
+        self::assertArrayHasKey(Digits::NOT_DIGITS, $messages);
+    }
+
+    public function testThatAMissingRuleIsAnError(): void
+    {
+        $this->expectException(InvalidArgumentException::class);
+        $this->expectExceptionMessage('The `rule` option must be callable');
+        /** @psalm-suppress InvalidArgument */
+        new Conditional($this->factory, []);
+    }
+
+    public function testThatANonCallableRuleIsAnError(): void
+    {
+        $this->expectException(InvalidArgumentException::class);
+        $this->expectExceptionMessage('The `rule` option must be callable');
+        /** @psalm-suppress InvalidArgument */
+        new Conditional($this->factory, ['rule' => [$this, 'notAKnownMethod']]);
+    }
+
+    /**
+     * @throws ContainerExceptionInterface
+     * @throws NotFoundExceptionInterface
+     */
+    public function testPluginManagerIntegration(): void
+    {
+        $container = new ServiceManager((new ConfigProvider())->getDependencyConfig());
+        $plugins   = $container->get(ValidatorPluginManager::class);
+        self::assertInstanceOf(ValidatorPluginManager::class, $plugins);
+        $validator = $plugins->build(Conditional::class, [
+            'rule'       => static function (array $context): bool {
+                return ($context['trigger'] ?? null) === true;
+            },
+            'validators' => [
+                ['name' => Digits::class],
+            ],
+        ]);
+
+        self::assertTrue($validator->isValid('123', ['trigger' => true]));
+        self::assertFalse($validator->isValid('Foo', ['trigger' => true]));
+        self::assertTrue($validator->isValid('Foo', ['trigger' => false]));
+    }
+}