Repository: marshmallow-code/marshmallow

Issue:
And validator
Hello everyone!
I would like to add a new validator to combine multiple validators into one. Simple example:

```py
def calculate_age(birthday: date) -> int:
    today = date.today()
    offset = (today.month, today.day) < (birthday.month, birthday.day)
    return today.year - birthday.year - offset

def validate_not_future_date(value: date) -> None:
    if value > date.today():
        raise ValidationError("Not future date.")

def validate_14_years_old(value: date) -> None:
    if calculate_age(value) < 14:
        raise ValidationError("Must be 14 years old.")

class PassportSchema(Schema):
    issued_at = fields.Date(
        "%Y-%m-%d",
        validate=[
            validate_not_future_date,
            validate_14_years_old,
        ],
    )

    @post_load
    def make_passport(self, data: dict, **kwargs) -> Passport:
        return Passport(**data)

@attr.s(auto_attribs=True, slots=True, frozen=True)
class Passport:
    issued_at: date

    def __attrs_post_init__(self) -> None:
        validate_not_future_date(self.issued_at)
        validate_14_years_old(self.issued_at)
```

In this example, I am forced to pass validators as a list in the `issued_at` field (in `PassportSchema`) and call two functions to validate a single value in `Passport` entity.
I would like to combine the two functions into one and turn them into a single validator:

```py
T = TypeVar("T")

Validator = Callable[[T], T]

@attr.s(auto_attribs=True, slots=True, frozen=True)
class _AndValidator:
    validators: Sequence[Validator]

    def __call__(self, value: T) -> T:
        for validator in self.validators:
            validator(value)
        return value

def and_validator(*validators) -> _AndValidator:
    return _AndValidator(validators)

validate_passport_issued_at = and_validator(
    validate_14_years_old,
    validate_not_future_date,
)
```

Then the example above will look much more concise:

```py
class PassportSchema(Schema):
    issued_at = fields.Date("%Y-%m-%d", validate=validate_passport_issued_at)

    @post_load
    def make_passport(self, data: dict, **kwargs) -> Passport:
        return Passport(**data)

@attr.s(auto_attribs=True, slots=True, frozen=True)
class Passport:
    issued_at: date

    def __attrs_post_init__(self) -> None:
        validate_passport_issued_at(self.issued_at)
```

I would like to add such functionality to the `marshmallow.validate` package.

Discussion:

Patch:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 93218bac..261ba45a 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -6,6 +6,8 @@ Changelog
 
 Features:
 
+- Add ``validate.And`` (:issue:`1768`).
+  Thanks :user:`rugleb` for the suggestion.
 - Let ``Field``s be accessed by name as ``Schema`` attributes (:pr:`1631`).
 
 Other changes:
diff --git a/src/marshmallow/fields.py b/src/marshmallow/fields.py
index 6b3699b3..f6a208e3 100644
--- a/src/marshmallow/fields.py
+++ b/src/marshmallow/fields.py
@@ -25,7 +25,7 @@ from marshmallow.exceptions import (
     StringNotCollectionError,
     FieldInstanceResolutionError,
 )
-from marshmallow.validate import Validator, Length
+from marshmallow.validate import And, Length
 from marshmallow.warnings import RemovedInMarshmallow4Warning
 
 __all__ = [
@@ -242,21 +242,11 @@ class Field(FieldABC):
         """Perform validation on ``value``. Raise a :exc:`ValidationError` if validation
         does not succeed.
         """
-        errors = []
-        kwargs = {}
-        for validator in self.validators:
-            try:
-                r = validator(value)
-                if not isinstance(validator, Validator) and r is False:
-                    raise self.make_error("validator_failed")
-            except ValidationError as err:
-                kwargs.update(err.kwargs)
-                if isinstance(err.messages, dict):
-                    errors.append(err.messages)
-                else:
-                    errors.extend(err.messages)
-        if errors:
-            raise ValidationError(errors, **kwargs)
+        self._validate_all(value)
+
+    @property
+    def _validate_all(self):
+        return And(*self.validators, error=self.error_messages["validator_failed"])
 
     def make_error(self, key: str, **kwargs) -> ValidationError:
         """Helper method to make a `ValidationError` with an error message
diff --git a/src/marshmallow/types.py b/src/marshmallow/types.py
index 64f1d60c..7626994a 100644
--- a/src/marshmallow/types.py
+++ b/src/marshmallow/types.py
@@ -8,3 +8,4 @@ import typing
 
 StrSequenceOrSet = typing.Union[typing.Sequence[str], typing.Set[str]]
 Tag = typing.Union[str, typing.Tuple[str, bool]]
+Validator = typing.Callable[[typing.Any], typing.Any]
diff --git a/src/marshmallow/validate.py b/src/marshmallow/validate.py
index 4e19c056..9c637fc9 100644
--- a/src/marshmallow/validate.py
+++ b/src/marshmallow/validate.py
@@ -40,6 +40,56 @@ class Validator(ABC):
         ...
 
 
+class And(Validator):
+    """Compose multiple validators and combine their error messages.
+
+    Example: ::
+
+        from marshmallow import validate, ValidationError
+
+        def is_even(value):
+            if value % 2 != 0:
+                raise ValidationError("Not an even value.")
+
+        validator = validate.And(validate.Range(min=0), is_even)
+        validator(-1)
+        # ValidationError: ['Must be greater than or equal to 0.', 'Not an even value.']
+
+    :param validators: Validators to combine.
+    :param error: Error message to use when a validator returns ``False``.
+    """
+
+    default_error_message = "Invalid value."
+
+    def __init__(
+        self, *validators: types.Validator, error: typing.Optional[str] = None
+    ):
+        self.validators = tuple(validators)
+        self.error = error or self.default_error_message  # type: str
+
+    def _repr_args(self) -> str:
+        return "validators={!r}".format(self.validators)
+
+    def __call__(self, value: typing.Any) -> typing.Any:
+        errors = []
+        kwargs = {}
+        for validator in self.validators:
+            try:
+                r = validator(value)
+                if not isinstance(validator, Validator) and r is False:
+                    raise ValidationError(self.error)
+            except ValidationError as err:
+                kwargs.update(err.kwargs)
+                if isinstance(err.messages, dict):
+                    errors.append(err.messages)
+                else:
+                    # FIXME : Get rid of cast
+                    errors.extend(typing.cast(list, err.messages))
+        if errors:
+            raise ValidationError(errors, **kwargs)
+        return value
+
+
 class URL(Validator):
     """Validate a URL.
 


Test patch:
diff --git a/tests/test_deserialization.py b/tests/test_deserialization.py
index 911cb174..3abe99a9 100644
--- a/tests/test_deserialization.py
+++ b/tests/test_deserialization.py
@@ -823,6 +823,11 @@ class TestFieldDeserialization:
             field.deserialize("invalidemail")
         assert excinfo.value.args[0][0] == "Not a valid email address."
 
+        field = fields.Email(validate=[validate.Length(min=12)])
+        with pytest.raises(ValidationError) as excinfo:
+            field.deserialize("foo@bar.com")
+        assert excinfo.value.args[0][0] == "Shorter than minimum length 12."
+
     # regression test for https://github.com/marshmallow-code/marshmallow/issues/1400
     def test_email_field_non_list_validators(self):
         field = fields.Email(validate=(validate.Length(min=9),))
diff --git a/tests/test_validate.py b/tests/test_validate.py
index 461215f9..0dc70c1e 100644
--- a/tests/test_validate.py
+++ b/tests/test_validate.py
@@ -890,3 +890,25 @@ def test_containsnoneof_mixing_types():
 
     with pytest.raises(ValidationError):
         validate.ContainsNoneOf([1, 2, 3])((1,))
+
+
+def is_even(value):
+    if value % 2 != 0:
+        raise ValidationError("Not an even value.")
+
+
+def test_and():
+    validator = validate.And(validate.Range(min=0), is_even)
+    assert validator(2)
+    with pytest.raises(ValidationError) as excinfo:
+        validator(-1)
+    errors = excinfo.value.messages
+    assert errors == ["Must be greater than or equal to 0.", "Not an even value."]
+
+    validator_with_composition = validate.And(validator, validate.Range(max=6))
+    assert validator_with_composition(4)
+    with pytest.raises(ValidationError) as excinfo:
+        validator_with_composition(7)
+
+    errors = excinfo.value.messages
+    assert errors == ["Not an even value.", "Must be less than or equal to 6."]