diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 074c46a..6ccf921 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -4,6 +4,19 @@ Changelog All notable changes to this project will be documented in this file. +[0.7.5] - 2025-10-17 +-------------------- + +Added +^^^^^ +- **Nested InputFilter Support**: New feature to validate nested dictionary + structures using InputFilters. Use the ``input_filter`` parameter in + ``field()`` to specify an InputFilter class for nested validation. This + enables composition of complex validation structures with multiple levels + of nesting. See :doc:`Field Decorator documentation ` + for more details. + + [0.7.4] - 2025-10-08 -------------------- diff --git a/docs/source/options/field_decorator.rst b/docs/source/options/field_decorator.rst index aec3957..530234b 100644 --- a/docs/source/options/field_decorator.rst +++ b/docs/source/options/field_decorator.rst @@ -378,6 +378,58 @@ Computed fields are: computed=lambda data: data['subtotal'] + data.get('tax', 0) ) +input_filter +~~~~~~~~~~~~ + +**Type**: ``type`` +**Default**: ``None`` + +Specify an InputFilter class to use for nested validation. When this parameter is +provided, the field value must be a dictionary and will be validated against the +nested InputFilter's rules. + +This allows you to compose complex validation structures by nesting InputFilters +within each other, enabling validation of nested objects and hierarchical data +structures. + +**Key Features:** + +- Validates nested dictionary structures +- Applies all filters and validators from the nested InputFilter +- Supports multiple levels of nesting +- Provides clear error messages with field context + +.. code-block:: python + + from flask_inputfilter import InputFilter + from flask_inputfilter.declarative import field + from flask_inputfilter.validators import IsIntegerValidator, IsStringValidator + + class UserInputFilter(InputFilter): + id: int = field(required=True, validators=[IsIntegerValidator()]) + name: str = field(required=True, validators=[IsStringValidator()]) + email: str = field(required=True, validators=[IsStringValidator()]) + + class OrderInputFilter(InputFilter): + quantity: int = field(required=True, validators=[IsIntegerValidator()]) + user: dict = field(required=True, input_filter=UserInputFilter) + +**Error Handling:** + +If nested validation fails, the error will include context about which field failed: + +.. code-block:: python + + # If user.name is missing: + # ValidationError: {'user': "Nested validation failed for field 'user': {'name': \"Field 'name' is required.\"}"} + +**Important Notes:** + +- The field value must be a dictionary, otherwise a validation error is raised +- If the field is optional (``required=False``) and the value is ``None``, nested validation is skipped +- All filters and validators from the nested InputFilter are applied +- The nested InputFilter can also have its own nested fields, allowing unlimited nesting depth + Advanced Field Patterns ----------------------- diff --git a/docs/source/options/global_decorators.rst b/docs/source/options/global_decorators.rst index fa53965..60f7e97 100644 --- a/docs/source/options/global_decorators.rst +++ b/docs/source/options/global_decorators.rst @@ -344,29 +344,3 @@ Combining Global Decorators # Model association model(User) - -Hierarchical Configuration -~~~~~~~~~~~~~~~~~~~~~~~~~~ - -.. code-block:: python - - class BaseUserFilter(InputFilter): - # Base global configuration - global_filter(StringTrimFilter()) - global_validator(IsStringValidator()) - - class StandardUserFilter(BaseUserFilter): - username = field(required=True) - email = field(required=True) - - # Additional processing - global_validator(NotEmptyValidator()) - - class AdminUserFilter(StandardUserFilter): - role = field(required=True, default="admin") - permissions = field(required=False, default=[]) - - # Admin-specific validation - global_validator(SecurityValidator()) - condition(AdminPermissionCondition()) - # errors will contain both field-level and condition-level errors diff --git a/flask_inputfilter/_input_filter.pyx b/flask_inputfilter/_input_filter.pyx index 776f739..aaa2896 100644 --- a/flask_inputfilter/_input_filter.pyx +++ b/flask_inputfilter/_input_filter.pyx @@ -288,6 +288,7 @@ cdef class InputFilter: attr_value.external_api, attr_value.copy, attr_value.computed, + attr_value.input_filter, ) conditions = getattr(base_cls, "_conditions", None) diff --git a/flask_inputfilter/declarative/_field_descriptor.pxd b/flask_inputfilter/declarative/_field_descriptor.pxd index e3c7572..9b9709c 100644 --- a/flask_inputfilter/declarative/_field_descriptor.pxd +++ b/flask_inputfilter/declarative/_field_descriptor.pxd @@ -9,10 +9,12 @@ cdef class FieldDescriptor: object fallback list[BaseFilter] filters list[BaseValidator] validators - list steps + list[BaseFilter | BaseValidator] steps ExternalApiConfig external_api str copy object computed + object input_filter + cdef public: str name diff --git a/flask_inputfilter/declarative/_field_descriptor.pyx b/flask_inputfilter/declarative/_field_descriptor.pyx index fe49a3a..deff15b 100644 --- a/flask_inputfilter/declarative/_field_descriptor.pyx +++ b/flask_inputfilter/declarative/_field_descriptor.pyx @@ -3,6 +3,7 @@ # cython: wraparound=False # cython: cdivision=True +from flask_inputfilter.models.cimports cimport BaseFilter, BaseValidator, ExternalApiConfig cdef class FieldDescriptor: """ @@ -18,14 +19,23 @@ cdef class FieldDescriptor: - **default** (*Any*): Default value if field is missing. - **fallback** (*Any*): Fallback value if validation fails. - **filters** (*Optional[list[BaseFilter]]*): List of filters to apply. - - **validators** (*Optional[list[BaseValidator]]*): List of validators to apply. - - **steps** (*Optional[list[Union[BaseFilter, BaseValidator]]]*): List of combined filters and validators. - - **external_api** (*Optional[ExternalApiConfig]*): External API configuration. - - **copy** (*Optional[str]*): Field to copy value from if this field is missing. + - **validators** (*Optional[list[BaseValidator]]*): List of validators + to apply. + - **steps** (*Optional[list[Union[BaseFilter, BaseValidator]]]*): List of + combined filters and validators. + - **external_api** (*Optional[ExternalApiConfig]*): External API + configuration. + - **copy** (*Optional[str]*): Field to copy value from if this field + is missing. + - **computed** (*Optional[Callable[[dict[str, Any]], Any]]*): A callable + that computes the field value from validated data. + - **input_filter** (*Optional[type]*): An InputFilter class for + nested validation. **Expected Behavior:** - Automatically registers field configuration during class creation and provides + Automatically registers field configuration during class creation and + provides attribute access to validated field values. """ @@ -34,12 +44,13 @@ cdef class FieldDescriptor: bint required = False, object default = None, object fallback = None, - list filters = None, - list validators = None, - list steps = None, - object external_api = None, + list[BaseFilter] filters = None, + list[BaseValidator] validators = None, + list[BaseFilter | BaseValidator] steps = None, + ExternalApiConfig external_api = None, str copy = None, object computed = None, + object input_filter = None, ) -> None: """ Initialize a field descriptor. @@ -62,6 +73,8 @@ cdef class FieldDescriptor: from. - **computed** (*Optional[Callable[[dict[str, Any]], Any]]*): A callable that computes the field value from validated data. + - **input_filter** (*Optional[type]*): An InputFilter class + for nested validation. """ self.required = required self._default = default @@ -72,6 +85,7 @@ cdef class FieldDescriptor: self.external_api = external_api self.copy = copy self.computed = computed + self.input_filter = input_filter self.name = None @property @@ -147,6 +161,11 @@ cdef class FieldDescriptor: f"required={self.required}, " f"default={self.default!r}, " f"filters={len(self.filters)}, " - f"validators={len(self.validators)}" + f"validators={len(self.validators)}, " + f"steps={len(self.steps)}, " + f"external_api={self.external_api!r}, " + f"copy={self.copy!r}, " + f"computed={self.computed!r}, " + f"input_filter={self.input_filter!r}" f")" ) diff --git a/flask_inputfilter/declarative/field.py b/flask_inputfilter/declarative/field.py index 36dfe60..c759c93 100644 --- a/flask_inputfilter/declarative/field.py +++ b/flask_inputfilter/declarative/field.py @@ -23,6 +23,7 @@ def field( external_api: Optional[ExternalApiConfig] = None, copy: Optional[str] = None, computed: Optional[Any] = None, + input_filter: Optional[type] = None, ) -> FieldDescriptor: """ Create a field descriptor for declarative field definition. @@ -51,6 +52,9 @@ def field( - **computed** (*Optional[Callable[[dict[str, Any]], Any]]*): A callable that computes the field value from validated data. Default: None. + - **input_filter** (*Optional[type]*): An InputFilter class to use + for nested validation. When specified, the field value (must be a dict) + will be validated against the nested InputFilter's rules. Default: None. **Returns:** @@ -78,4 +82,5 @@ class UserInputFilter(InputFilter): external_api=external_api, copy=copy, computed=computed, + input_filter=input_filter, ) diff --git a/flask_inputfilter/declarative/field_descriptor.py b/flask_inputfilter/declarative/field_descriptor.py index 0eed186..1029493 100644 --- a/flask_inputfilter/declarative/field_descriptor.py +++ b/flask_inputfilter/declarative/field_descriptor.py @@ -34,6 +34,8 @@ class FieldDescriptor: is missing. - **computed** (*Optional[Callable[[dict[str, Any]], Any]]*): A callable that computes the field value from validated data. + - **input_filter** (*Optional[type]*): An InputFilter class for + nested validation. **Expected Behavior:** @@ -53,31 +55,8 @@ def __init__( external_api: Optional[ExternalApiConfig] = None, copy: Optional[str] = None, computed: Optional[Any] = None, + input_filter: Optional[type] = None, ) -> None: - """ - Initialize a field descriptor. - - **Parameters:** - - - **required** (*bool*): Whether the field is required. - - **default** (*Any*): The default value of the field. - - **fallback** (*Any*): The fallback value of the field, if - validations fail or field is None, although it is required. - - **filters** (*Optional[list[BaseFilter]]*): The filters to apply to - the field value. - - **validators** (*Optional[list[BaseValidator]]*): The validators to - apply to the field value. - - **steps** (*Optional[list[Union[BaseFilter, BaseValidator]]]*): - Allows - to apply multiple filters and validators in a specific order. - - **external_api** (*Optional[ExternalApiConfig]*): Configuration - for an - external API call. - - **copy** (*Optional[str]*): The name of the field to copy the value - from. - - **computed** (*Optional[Callable[[dict[str, Any]], Any]]*): A - callable that computes the field value from validated data. - """ self.required = required self.default = default self.fallback = fallback @@ -87,6 +66,7 @@ def __init__( self.external_api = external_api self.copy = copy self.computed = computed + self.input_filter = input_filter self.name: Optional[str] = None def __set_name__(self, owner: type, name: str) -> None: @@ -146,5 +126,6 @@ def __repr__(self) -> str: f"external_api={self.external_api!r}, " f"copy={self.copy!r}, " f"computed={self.computed!r}, " + f"input_filter={self.input_filter!r}" f")" ) diff --git a/flask_inputfilter/declarative/field_descriptor.pyi b/flask_inputfilter/declarative/field_descriptor.pyi index fe45794..3f31396 100644 --- a/flask_inputfilter/declarative/field_descriptor.pyi +++ b/flask_inputfilter/declarative/field_descriptor.pyi @@ -31,6 +31,10 @@ class FieldDescriptor: configuration. - **copy** (*Optional[str]*): Field to copy value from if this field is missing. + - **computed** (*Optional[Callable[[dict[str, Any]], Any]]*): A + callable that computes the field value from validated data. + - **input_filter** (*Optional[type]*): An InputFilter class + for nested validation. **Expected Behavior:** @@ -49,6 +53,7 @@ class FieldDescriptor: copy: Optional[str] name: Optional[str] computed: Optional[Any] + input_filter: Optional[type] def __init__( self, @@ -61,6 +66,7 @@ class FieldDescriptor: external_api: Optional[ExternalApiConfig] = None, copy: Optional[str] = None, computed: Optional[Any] = None, + input_filter: Optional[type] = None, ) -> None: ... def __set_name__(self, owner: type, name: str) -> None: ... def __get__(self, obj: Any, objtype: Optional[type] = None) -> Any: ... diff --git a/flask_inputfilter/input_filter.py b/flask_inputfilter/input_filter.py index 13559b0..2cbd025 100644 --- a/flask_inputfilter/input_filter.py +++ b/flask_inputfilter/input_filter.py @@ -249,6 +249,7 @@ def _register_decorator_components(self) -> None: attr_value.external_api, attr_value.copy, attr_value.computed, + attr_value.input_filter, ) conditions = getattr(base_cls, "_conditions", None) diff --git a/flask_inputfilter/mixins/validation_mixin/_validation_mixin.pxd b/flask_inputfilter/mixins/validation_mixin/_validation_mixin.pxd index fd337bd..780647e 100644 --- a/flask_inputfilter/mixins/validation_mixin/_validation_mixin.pxd +++ b/flask_inputfilter/mixins/validation_mixin/_validation_mixin.pxd @@ -29,4 +29,16 @@ cdef class ValidationMixin: ) @staticmethod - cdef inline object get_field_value(str field_name, FieldModel field_info, dict[str, Any] data, dict[str, Any] validated_data) \ No newline at end of file + cdef dict apply_nested_input_filter( + str field_name, + object input_filter_class, + object value + ) + + @staticmethod + cdef inline object get_field_value( + str field_name, + FieldModel field_info, + dict[str, Any] data, + dict[str, Any] validated_data + ) diff --git a/flask_inputfilter/mixins/validation_mixin/_validation_mixin.pyx b/flask_inputfilter/mixins/validation_mixin/_validation_mixin.pyx index 1896005..b78a3cf 100644 --- a/flask_inputfilter/mixins/validation_mixin/_validation_mixin.pyx +++ b/flask_inputfilter/mixins/validation_mixin/_validation_mixin.pyx @@ -10,7 +10,7 @@ from typing import Any from flask_inputfilter.exceptions import ValidationError from flask_inputfilter.mixins.cimports cimport ExternalApiMixin -from flask_inputfilter.models.cimports cimport BaseFilter, BaseValidator, FieldModel +from flask_inputfilter.models.cimports cimport BaseCondition, BaseFilter, BaseValidator, FieldModel cdef class ValidationMixin: @@ -325,12 +325,57 @@ cdef class ValidationMixin: value ) + if field_info.input_filter is not None and value is not None: + value = ValidationMixin.apply_nested_input_filter( + field_name, + field_info.input_filter, + value + ) + validated_data[field_name] = value except ValidationError as e: errors[field_name] = str(e) return validated_data, errors + @staticmethod + cdef dict apply_nested_input_filter( + str field_name, + object input_filter_class, + object value + ): + """ + Apply nested InputFilter validation to a field value. + + **Parameters:** + + - **field_name** (*str*): The name of the field being validated. + - **input_filter_class** (*type*): The InputFilter class to use for + validation. + - **value** (*Any*): The value to validate (must be a dict). + + **Returns:** + + - (*dict[str, Any]*): The validated nested data as a dictionary. + + **Raises:** + + - **ValidationError**: If the value is not a dict or if nested + validation fails. + """ + if not isinstance(value, dict): + raise ValidationError( + f"Field '{field_name}' must be a dict for nested InputFilter " + f"validation, got {type(value).__name__}." + ) + + try: + return input_filter_class().validate_data(value) + except ValidationError as e: + raise ValidationError( + f"Nested validation failed for field '{field_name}': {str(e)}" + ) from e + @staticmethod cdef inline object get_field_value( str field_name, @@ -372,4 +417,4 @@ cdef class ValidationMixin: field_info.fallback, validated_data ) - return data.get(field_name) \ No newline at end of file + return data.get(field_name) diff --git a/flask_inputfilter/mixins/validation_mixin/validation_mixin.py b/flask_inputfilter/mixins/validation_mixin/validation_mixin.py index 4c40f1b..1e175bb 100644 --- a/flask_inputfilter/mixins/validation_mixin/validation_mixin.py +++ b/flask_inputfilter/mixins/validation_mixin/validation_mixin.py @@ -237,12 +237,55 @@ def validate_fields( field_name, field_info, value ) + if field_info.input_filter is not None and value is not None: + value = ValidationMixin.apply_nested_input_filter( + field_name, field_info.input_filter, value + ) + validated_data[field_name] = value except ValidationError as e: errors[field_name] = str(e) return validated_data, errors + @staticmethod + def apply_nested_input_filter( + field_name: str, + input_filter_class: type, + value: Any, + ) -> dict[str, Any]: + """ + Apply nested InputFilter validation to a field value. + + **Parameters:** + + - **field_name** (*str*): The name of the field being validated. + - **input_filter_class** (*type*): The InputFilter class to + use for validation. + - **value** (*Any*): The value to validate (must be a dict). + + **Returns:** + + - (*dict[str, Any]*): The validated nested data as a dictionary. + + **Raises:** + + - **ValidationError**: If the value is not a dict or if nested + validation fails. + """ + if not isinstance(value, dict): + raise ValidationError( + f"Field '{field_name}' must be a dict for nested InputFilter " + f"validation, got {type(value).__name__}." + ) + + try: + return input_filter_class().validate_data(value) + except ValidationError as e: + raise ValidationError( + f"Nested validation failed for field '{field_name}': {e!s}" + ) from e + @staticmethod def get_field_value( field_name: str, diff --git a/flask_inputfilter/models/field_model/_field_model.pxd b/flask_inputfilter/models/field_model/_field_model.pxd index 13b49fe..a50605a 100644 --- a/flask_inputfilter/models/field_model/_field_model.pxd +++ b/flask_inputfilter/models/field_model/_field_model.pxd @@ -8,7 +8,8 @@ cdef class FieldModel: object fallback list[BaseFilter] filters list[BaseValidator] validators - list steps + list[BaseFilter | BaseValidator] steps ExternalApiConfig external_api str copy object computed + object input_filter diff --git a/flask_inputfilter/models/field_model/_field_model.pyx b/flask_inputfilter/models/field_model/_field_model.pyx index cd35ffb..a50acd1 100644 --- a/flask_inputfilter/models/field_model/_field_model.pyx +++ b/flask_inputfilter/models/field_model/_field_model.pyx @@ -43,7 +43,8 @@ cdef class FieldModel: list steps=None, ExternalApiConfig external_api=None, str copy=None, - object computed=None + object computed=None, + object input_filter=None ) -> None: self.required = required self._default = default @@ -67,3 +68,4 @@ cdef class FieldModel: self.external_api = external_api self.copy = copy self.computed = computed + self.input_filter = input_filter diff --git a/flask_inputfilter/models/field_model/field_model.py b/flask_inputfilter/models/field_model/field_model.py index bc22712..6eaedcd 100644 --- a/flask_inputfilter/models/field_model/field_model.py +++ b/flask_inputfilter/models/field_model/field_model.py @@ -26,3 +26,4 @@ class FieldModel: external_api: Optional[ExternalApiConfig] = None copy: Optional[str] = None computed: Optional[Callable[[dict[str, Any]], Any]] = None + input_filter: Optional[type] = None diff --git a/pyproject.toml b/pyproject.toml index ba9c64f..9199f7b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "flask_inputfilter" -version = "0.7.4" +version = "0.7.5" description = "A library to easily filter and validate input data in Flask applications" readme = "README.md" keywords = [ diff --git a/scripts/clean.sh b/scripts/clean similarity index 100% rename from scripts/clean.sh rename to scripts/clean diff --git a/tests/input_filter/test_nested_input_filter.py b/tests/input_filter/test_nested_input_filter.py new file mode 100644 index 0000000..76461bd --- /dev/null +++ b/tests/input_filter/test_nested_input_filter.py @@ -0,0 +1,552 @@ +"""Tests for nested InputFilter feature.""" + +from __future__ import annotations + +import pytest + +from flask_inputfilter import InputFilter +from flask_inputfilter.declarative import field +from flask_inputfilter.exceptions import ValidationError +from flask_inputfilter.filters import ToIntegerFilter, StringTrimFilter +from flask_inputfilter.validators import IsIntegerValidator, IsStringValidator + + +class TestNestedInputFilter: + """Test nested InputFilter validation.""" + + def test_basic_nested_validation(self) -> None: + """Test basic nested InputFilter validation.""" + + class UserFilter(InputFilter): + id = field( + required=True, + filters=[ToIntegerFilter()], + validators=[IsIntegerValidator()], + ) + name = field( + required=True, + filters=[StringTrimFilter()], + validators=[IsStringValidator()], + ) + + class OrderFilter(InputFilter): + quantity = field( + required=True, + filters=[ToIntegerFilter()], + validators=[IsIntegerValidator()], + ) + user = field(required=True, input_filter=UserFilter) + + validated_data = OrderFilter().validate_data({ + "quantity": "5", + "user": {"id": "123", "name": " John Doe "}, + }) + + assert validated_data["quantity"] == 5 + assert validated_data["user"]["id"] == 123 + assert validated_data["user"]["name"] == "John Doe" + + def test_nested_validation_missing_required_field(self) -> None: + """Test nested validation with missing required field.""" + + class UserFilter(InputFilter): + id = field(required=True, validators=[IsIntegerValidator()]) + name = field(required=True, validators=[IsStringValidator()]) + + class OrderFilter(InputFilter): + quantity = field(required=True, validators=[IsIntegerValidator()]) + user = field(required=True, input_filter=UserFilter) + + with pytest.raises(ValidationError) as exc_info: + OrderFilter().validate_data({"quantity": 5, "user": {"id": 123}}) + + errors = exc_info.value.args[0] + assert "user" in errors + assert "name" in str(errors["user"]) + + def test_nested_validation_invalid_type(self) -> None: + """Test nested validation with invalid type (not a dict).""" + + class UserFilter(InputFilter): + id = field(required=True) + + class OrderFilter(InputFilter): + user = field(required=True, input_filter=UserFilter) + + with pytest.raises(ValidationError) as exc_info: + OrderFilter().validate_data({"user": "invalid_string"}) + + errors = exc_info.value.args[0] + assert "user" in errors + assert "must be a dict" in errors["user"] + + def test_nested_validation_optional_field(self) -> None: + """Test nested validation with optional nested field.""" + + class UserFilter(InputFilter): + id = field(required=True, validators=[IsIntegerValidator()]) + + class OrderFilter(InputFilter): + quantity = field(required=True, validators=[IsIntegerValidator()]) + user = field(required=False, input_filter=UserFilter) + + validated_data = OrderFilter().validate_data({"quantity": 5}) + + assert validated_data["quantity"] == 5 + assert validated_data["user"] is None + + def test_nested_validation_with_none_value(self) -> None: + """Test nested validation when value is None.""" + + class UserFilter(InputFilter): + id = field(required=True) + + class OrderFilter(InputFilter): + user = field(required=False, input_filter=UserFilter) + + validated_data = OrderFilter().validate_data({"user": None}) + + # None should bypass nested validation + assert validated_data["user"] is None + + def test_multiple_levels_of_nesting(self) -> None: + """Test multiple levels of nested InputFilters.""" + + class AddressFilter(InputFilter): + city = field(required=True, validators=[IsStringValidator()]) + zipcode = field(required=True, validators=[IsStringValidator()]) + + class UserFilter(InputFilter): + id = field(required=True, validators=[IsIntegerValidator()]) + name = field(required=True, validators=[IsStringValidator()]) + address = field(required=True, input_filter=AddressFilter) + + class OrderFilter(InputFilter): + quantity = field(required=True, validators=[IsIntegerValidator()]) + user = field(required=True, input_filter=UserFilter) + + validated_data = OrderFilter().validate_data({ + "quantity": 10, + "user": { + "id": 123, + "name": "John Doe", + "address": {"city": "New York", "zipcode": "10001"}, + }, + }) + + assert validated_data["quantity"] == 10 + assert validated_data["user"]["id"] == 123 + assert validated_data["user"]["name"] == "John Doe" + assert validated_data["user"]["address"]["city"] == "New York" + assert validated_data["user"]["address"]["zipcode"] == "10001" + + def test_nested_validation_with_filters_and_validators(self) -> None: + """Test nested validation with both filters and validators.""" + + class UserFilter(InputFilter): + id = field( + required=True, + filters=[ToIntegerFilter()], + validators=[IsIntegerValidator()], + ) + email = field( + required=True, + filters=[StringTrimFilter()], + validators=[IsStringValidator()], + ) + + class OrderFilter(InputFilter): + quantity = field( + required=True, + filters=[ToIntegerFilter()], + validators=[IsIntegerValidator()], + ) + user = field(required=True, input_filter=UserFilter) + + validated_data = OrderFilter().validate_data({ + "quantity": "15", + "user": {"id": "456", "email": " test@example.com "}, + }) + + assert validated_data["quantity"] == 15 + assert validated_data["user"]["id"] == 456 + assert validated_data["user"]["email"] == "test@example.com" + + def test_nested_validation_field_descriptor_access(self) -> None: + """Test accessing nested fields via field descriptor.""" + + class UserFilter(InputFilter): + id: int = field(required=True, validators=[IsIntegerValidator()]) + name: str = field(required=True, validators=[IsStringValidator()]) + + class OrderFilter(InputFilter): + quantity: int = field( + required=True, validators=[IsIntegerValidator()] + ) + user: dict = field(required=True, input_filter=UserFilter) + + order_filter = OrderFilter() + order_filter.validate_data({ + "quantity": 5, + "user": {"id": 123, "name": "John Doe"}, + }) + + assert order_filter.quantity == 5 + assert order_filter.user["id"] == 123 + assert order_filter.user["name"] == "John Doe" + + def test_nested_validation_with_default_values(self) -> None: + """Test nested validation with default values.""" + + class UserFilter(InputFilter): + id = field(required=True, validators=[IsIntegerValidator()]) + role = field(required=False, default="user") + + class OrderFilter(InputFilter): + quantity = field(required=True, validators=[IsIntegerValidator()]) + user = field(required=True, input_filter=UserFilter) + + validated_data = OrderFilter().validate_data({ + "quantity": 3, + "user": {"id": 789}, + }) + + assert validated_data["quantity"] == 3 + assert validated_data["user"]["id"] == 789 + assert validated_data["user"]["role"] == "user" + + def test_nested_validation_error_message_context(self) -> None: + """Test that nested validation errors include field context.""" + + class UserFilter(InputFilter): + id = field(required=True, validators=[IsIntegerValidator()]) + + class OrderFilter(InputFilter): + user = field(required=True, input_filter=UserFilter) + + with pytest.raises(ValidationError) as exc_info: + OrderFilter().validate_data({"user": {"id": "not_an_integer"}}) + + errors = exc_info.value.args[0] + assert "user" in errors + # Error message should indicate nested validation failed + assert "Nested validation failed" in errors["user"] + + def test_is_valid_with_nested_filters(self) -> None: + """Test is_valid method with nested InputFilters.""" + + class UserFilter(InputFilter): + id = field(required=True, validators=[IsIntegerValidator()]) + + class OrderFilter(InputFilter): + quantity = field(required=True, validators=[IsIntegerValidator()]) + user = field(required=True, input_filter=UserFilter) + + order_filter = OrderFilter() + order_filter.validate_data({"quantity": 5, "user": {"id": 123}}) + + assert order_filter.errors == {} + + order_filter2 = OrderFilter() + assert order_filter2.is_valid() is False + + try: + OrderFilter().validate_data({"quantity": 5, "user": {"id": "invalid"}}) + assert False, "Should have raised ValidationError" + except ValidationError as e: + errors = e.args[0] + assert "user" in errors + + def test_nested_validation_with_list_instead_of_dict(self) -> None: + """Test nested validation fails when list is provided instead of + dict.""" + + class UserFilter(InputFilter): + id = field(required=True) + + class OrderFilter(InputFilter): + user = field(required=True, input_filter=UserFilter) + + with pytest.raises(ValidationError) as exc_info: + OrderFilter().validate_data({"user": [1, 2, 3]}) + + errors = exc_info.value.args[0] + assert "user" in errors + assert "must be a dict" in errors["user"] + assert "list" in errors["user"] + + def test_nested_validation_with_integer_instead_of_dict(self) -> None: + """Test nested validation fails when integer is provided.""" + + class UserFilter(InputFilter): + id = field(required=True) + + class OrderFilter(InputFilter): + user = field(required=True, input_filter=UserFilter) + + with pytest.raises(ValidationError) as exc_info: + OrderFilter().validate_data({"user": 123}) + + errors = exc_info.value.args[0] + assert "user" in errors + assert "must be a dict" in errors["user"] + assert "int" in errors["user"] + + def test_nested_validation_empty_dict(self) -> None: + """Test nested validation with empty dict.""" + + class UserFilter(InputFilter): + id = field(required=True, validators=[IsIntegerValidator()]) + name = field(required=True, validators=[IsStringValidator()]) + + class OrderFilter(InputFilter): + user = field(required=True, input_filter=UserFilter) + + with pytest.raises(ValidationError) as exc_info: + OrderFilter().validate_data({"user": {}}) + + errors = exc_info.value.args[0] + assert "user" in errors + assert "Nested validation failed" in errors["user"] + + def test_nested_validation_with_fallback_values(self) -> None: + """Test nested validation with fallback values in nested filter.""" + + class UserFilter(InputFilter): + id = field( + required=True, + validators=[IsIntegerValidator()], + fallback=999, + ) + name = field(required=True, validators=[IsStringValidator()]) + + class OrderFilter(InputFilter): + user = field(required=True, input_filter=UserFilter) + + validated_data = OrderFilter().validate_data({ + "user": {"id": "invalid_int", "name": "John"}, + }) + + assert validated_data["user"]["id"] == 999 + assert validated_data["user"]["name"] == "John" + + def test_multiple_nested_fields_in_same_filter(self) -> None: + """Test multiple nested fields at the same level.""" + + class AddressFilter(InputFilter): + city = field(required=True, validators=[IsStringValidator()]) + zipcode = field(required=True, validators=[IsStringValidator()]) + + class ContactFilter(InputFilter): + email = field(required=True, validators=[IsStringValidator()]) + phone = field(required=True, validators=[IsStringValidator()]) + + class UserFilter(InputFilter): + name = field(required=True, validators=[IsStringValidator()]) + address = field(required=True, input_filter=AddressFilter) + contact = field(required=True, input_filter=ContactFilter) + + validated_data = UserFilter().validate_data({ + "name": "John Doe", + "address": {"city": "NYC", "zipcode": "10001"}, + "contact": {"email": "john@example.com", "phone": "555-1234"}, + }) + + assert validated_data["name"] == "John Doe" + assert validated_data["address"]["city"] == "NYC" + assert validated_data["address"]["zipcode"] == "10001" + assert validated_data["contact"]["email"] == "john@example.com" + assert validated_data["contact"]["phone"] == "555-1234" + + def test_nested_validation_with_optional_nested_fields(self) -> None: + """Test nested filter with optional fields inside.""" + + class MetadataFilter(InputFilter): + created_by = field(required=False, validators=[IsStringValidator()]) + updated_by = field(required=False, validators=[IsStringValidator()]) + + class OrderFilter(InputFilter): + quantity = field(required=True, validators=[IsIntegerValidator()]) + metadata = field(required=True, input_filter=MetadataFilter) + + validated_data = OrderFilter().validate_data({ + "quantity": 5, + "metadata": {}, + }) + + assert validated_data["quantity"] == 5 + assert validated_data["metadata"]["created_by"] is None + assert validated_data["metadata"]["updated_by"] is None + + def test_deeply_nested_validation_error_propagation(self) -> None: + """Test error propagation through multiple nesting levels.""" + + class AddressFilter(InputFilter): + city = field(required=True, validators=[IsStringValidator()]) + zipcode = field(required=True, validators=[IsIntegerValidator()]) + + class UserFilter(InputFilter): + id = field(required=True, validators=[IsIntegerValidator()]) + address = field(required=True, input_filter=AddressFilter) + + class OrderFilter(InputFilter): + user = field(required=True, input_filter=UserFilter) + + with pytest.raises(ValidationError) as exc_info: + OrderFilter().validate_data({ + "user": {"id": 123, "address": {"city": "NYC", "zipcode": "invalid"}}, + }) + + errors = exc_info.value.args[0] + assert "user" in errors + assert "Nested validation failed" in errors["user"] + + def test_nested_validation_with_extra_fields(self) -> None: + """Test nested validation ignores extra fields not defined in + filter.""" + + class UserFilter(InputFilter): + id = field(required=True, validators=[IsIntegerValidator()]) + name = field(required=True, validators=[IsStringValidator()]) + + class OrderFilter(InputFilter): + user = field(required=True, input_filter=UserFilter) + + validated_data = OrderFilter().validate_data({ + "user": { + "id": 123, + "name": "John", + "extra_field": "should be ignored", + "another_extra": 999, + }, + }) + + assert validated_data["user"]["id"] == 123 + assert validated_data["user"]["name"] == "John" + # Extra fields should not be in validated data + assert "extra_field" not in validated_data["user"] + assert "another_extra" not in validated_data["user"] + + def test_nested_validation_with_boolean_false_value(self) -> None: + """Test that False boolean value doesn't get treated as None.""" + + class SettingsFilter(InputFilter): + enabled = field(required=True) + + class ConfigFilter(InputFilter): + settings = field(required=True, input_filter=SettingsFilter) + + validated_data = ConfigFilter().validate_data({ + "settings": {"enabled": False}, + }) + + assert validated_data["settings"]["enabled"] is False + + def test_nested_validation_with_zero_value(self) -> None: + """Test that zero value doesn't get treated as None.""" + + class PriceFilter(InputFilter): + amount = field(required=True, validators=[IsIntegerValidator()]) + + class ProductFilter(InputFilter): + price = field(required=True, input_filter=PriceFilter) + + validated_data = ProductFilter().validate_data({ + "price": {"amount": 0}, + }) + + assert validated_data["price"]["amount"] == 0 + + def test_nested_validation_with_empty_string_value(self) -> None: + """Test nested validation with empty string value.""" + + class UserFilter(InputFilter): + name = field(required=True, validators=[IsStringValidator()]) + + class OrderFilter(InputFilter): + user = field(required=True, input_filter=UserFilter) + + validated_data = OrderFilter().validate_data({ + "user": {"name": ""}, + }) + + assert validated_data["user"]["name"] == "" + + def test_nested_filter_reusability(self) -> None: + """Test that the same nested filter class can be reused.""" + + class AddressFilter(InputFilter): + city = field(required=True, validators=[IsStringValidator()]) + + class UserFilter(InputFilter): + home_address = field(required=True, input_filter=AddressFilter) + work_address = field(required=True, input_filter=AddressFilter) + + validated_data = UserFilter().validate_data({ + "home_address": {"city": "NYC"}, + "work_address": {"city": "LA"}, + }) + + assert validated_data["home_address"]["city"] == "NYC" + assert validated_data["work_address"]["city"] == "LA" + + def test_nested_validation_with_computed_fields_in_parent(self) -> None: + """Test nested validation works with computed fields in parent.""" + + class UserFilter(InputFilter): + first_name = field(required=True, validators=[IsStringValidator()]) + last_name = field(required=True, validators=[IsStringValidator()]) + + class OrderFilter(InputFilter): + user = field(required=True, input_filter=UserFilter) + total = field(required=True, validators=[IsIntegerValidator()]) + total_with_tax = field( + computed=lambda data: data.get("total", 0) * 1.1 + ) + + validated_data = OrderFilter().validate_data({ + "user": {"first_name": "John", "last_name": "Doe"}, + "total": 100, + }) + + assert validated_data["user"]["first_name"] == "John" + assert validated_data["user"]["last_name"] == "Doe" + assert validated_data["total"] == 100 + assert abs(validated_data["total_with_tax"] - 110.0) < 0.0001 + + def test_nested_validation_preserves_field_order(self) -> None: + """Test that nested validation preserves field order.""" + + class UserFilter(InputFilter): + id = field(required=True, validators=[IsIntegerValidator()]) + name = field(required=True, validators=[IsStringValidator()]) + email = field(required=True, validators=[IsStringValidator()]) + + class OrderFilter(InputFilter): + user = field(required=True, input_filter=UserFilter) + + validated_data = OrderFilter().validate_data({ + "user": {"id": 123, "name": "John", "email": "john@example.com"}, + }) + + # All fields should be present + assert "id" in validated_data["user"] + assert "name" in validated_data["user"] + assert "email" in validated_data["user"] + + def test_nested_validation_with_copy_field_in_nested_filter(self) -> None: + """Test nested validation with copy field inside nested filter.""" + + class UserFilter(InputFilter): + id = field(required=True, validators=[IsIntegerValidator()]) + user_id_copy = field(copy="id") + + class OrderFilter(InputFilter): + user = field(required=True, input_filter=UserFilter) + + validated_data = OrderFilter().validate_data({ + "user": {"id": 123}, + }) + + assert validated_data["user"]["id"] == 123 + assert validated_data["user"]["user_id_copy"] == 123