Skip to content

Conditional Validation

Conditional validation allows applying validation rules based on the values of other fields or specific context conditions.

Basic required_if

The required_if validator is the simplest form of conditional validation:

from pyvalidx import ValidatedModel, field_validated
from pyvalidx.core import required_if, is_required

class PaymentModel(ValidatedModel):
    payment_method: str = field_validated(
        is_required('Payment method is required')
    )

    # Only required if payment_method is 'credit_card'
    card_number: str = field_validated(
        required_if('payment_method', 'credit_card', 'Card number required for credit card payments')
    )

    # Only required if payment_method is 'bank_transfer'
    bank_account: str = field_validated(
        required_if('payment_method', 'bank_transfer', 'Bank account required for transfers')
    )

# Valid usage - Credit card
payment1 = PaymentModel(
    payment_method='credit_card',
    card_number='1234-5678-9012-3456'
)

# Valid usage - Bank transfer
payment2 = PaymentModel(
    payment_method='bank_transfer',
    bank_account='123456789'
)

# Valid usage - Cash (no additional fields required)
payment3 = PaymentModel(payment_method='cash')

Custom Conditional Validation

Multiple Conditions

from pyvalidx.core import custom

def required_if_multiple(conditions: dict, message: str):
    '''Validator that requires field if multiple conditions are met'''
    def validator(value, context=None):
        if context is None:
            return True

        # Check if all conditions are met
        all_conditions_met = all(
            context.get(field) == expected_value
            for field, expected_value in conditions.items()
        )

        if all_conditions_met:
            return value is not None and str(value).strip() != ''
        return True

    validator.__message__ = message
    return validator

class ShippingModel(ValidatedModel):
    country: str = field_validated(is_required())
    shipping_method: str = field_validated(is_required())

    # Only required if country='US' AND shipping_method='express'
    signature_required: bool = field_validated(
        custom(
            required_if_multiple(
                {'country': 'US', 'shipping_method': 'express'},
                'Signature is required for express shipping in US'
            )
        )
    )

# Usage
shipping = ShippingModel(
    country='US',
    shipping_method='express',
    signature_required=True
)

Conditional Format Validation

def conditional_format_validator(condition_field: str, condition_value: str, pattern: str, message: str) -> Callable[[Any, Optional[Dict[str, Any]]], bool]:
    '''
    Validates format only when condition is met

    Args:
        condition_field (str): Field to check for condition
        condition_value (str): Value that makes format validation active
        pattern (str): Regex pattern to validate against
        message (str): Error message if validation fails

    Returns:
        Callable[[Any, Optional[Dict[str, Any]]], bool]: Validator function
    '''
    def validator(value, context=None):
        if context is None or value is None:
            return True

        if context.get(condition_field) == condition_value:
            import re
            return bool(re.match(pattern, str(value)))
        return True

    validator.__message__ = message
    return validator

class ContactModel(ValidatedModel):
    contact_type: str = field_validated(is_required())
    contact_value: str = field_validated(is_required())

    # Validate email format only if contact_type is "email"
    formatted_contact: str = field_validated(
        custom(
            conditional_format_validator(
                'contact_type',
                'email',
                r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$',
                'Invalid email format'
            )
        )
    )

# Usage
contact = ContactModel(
    contact_type='email',
    contact_value='user@example.com',
    formatted_contact='user@example.com'
)

Conditional Validation with Business Logic

Discount System

def discount_validation(value, context=None) -> bool:
    '''
    Validates discount based on customer type and order amount

    Args:
        value (Any): Discount value to validate
        context (Dict[str, Any], optional): Context containing customer_type and order_amount

    Returns:
        bool: True if discount is valid, False otherwise
    '''
    if value is None or context is None:
        return True

    customer_type = context.get('customer_type')
    order_amount = context.get('order_amount', 0)
    discount = float(value)

    # Business rules for discounts
    if customer_type == 'regular':
        max_discount = 10 if order_amount >= 100 else 5
    elif customer_type == 'premium':
        max_discount = 20 if order_amount >= 100 else 15
    elif customer_type == 'vip':
        max_discount = 30
    elif customer_type == 'employee':
        max_discount = 50
    else:
        return False

    return 0 <= discount <= max_discount

discount_validation.__message__ = 'Invalid discount for customer type or order amount'

class OrderModel(ValidatedModel):
    customer_type: str = field_validated(
        is_required(),
        is_in(['regular', 'premium', 'vip', 'employee'])
    )

    order_amount: float = field_validated(
        is_required(),
        is_positive()
    )

    discount_percentage: float = field_validated(
        custom(discount_validation, 'Invalid discount for customer type or order amount')
    )

# Valid usage
order = OrderModel(
    customer_type='premium',
    order_amount=150.0,
    discount_percentage=18.0
)

# Invalid usage - discount too high for regular customer
try:
    invalid_order = OrderModel(
        customer_type='regular',
        order_amount=50.0,
        discount_percentage=25.0
    )
except ValidationException as e:
    print(e.validations)

Advanced Conditional Validation Patterns

Cascade Validation

def conditional_requirement(condition_func: Callable[[Dict[str, Any]], bool], message: str) -> Callable[[Any, Optional[Dict[str, Any]]], bool]:
    '''
    Generic conditional requirement validator

    Args:
        condition_func (Callable[[Dict[str, Any]], bool]): Function that takes context and returns True if condition is met
        message (str): Error message if validation fails

    Returns:
        Callable[[Any, Optional[Dict[str, Any]]], bool]: Validator function
    '''
    def validator(value, context=None):
        if condition_func(context):
            return value is not None and str(value).strip() != ''
        return True

    validator.__message__ = message
    return validator

class EmployeeModel(ValidatedModel):
    employee_type: str = field_validated(
        is_required(),
        is_in(['full_time', 'part_time', 'contractor', 'intern'])
    )

    # Only for full-time and part-time employees
    benefits_eligible: bool = field_validated(
        custom(
            required_if_multiple(
                {'employee_type': 'full_time'},
                'Benefits eligibility required for full-time employees'
            )
        )
    )

    # Only if eligible for benefits
    health_plan: str = field_validated(
        custom(
            conditional_requirement(
                lambda ctx: ctx.get('benefits_eligible') == True,
                'Health plan selection required for benefits-eligible employees'
            )
        )
    )

At Least One Required

def at_least_one_required(fields: list, message: str) -> Callable[[Any, Optional[Dict[str, Any]]], bool]:
    '''
    Validates that at least one of the specified fields has a value

    Args:
        fields (list): List of field names to check
        message (str): Error message if validation fails

    Returns:
        Callable[[Any, Optional[Dict[str, Any]]], bool]: Validator function
    '''
    def validator(value, context=None):
        if context is None:
            return True

        # Check if at least one field has a value
        has_value = any(
            context.get(field) is not None and str(context.get(field)).strip() != ''
            for field in fields
        )

        return has_value

    validator.__message__ = message
    return validator

class ContactFormModel(ValidatedModel):
    name: str = field_validated(is_required())

    # At least one of these must be present
    email: str = field_validated(
        custom(
            at_least_one_required(['email', 'phone'], 'Either email or phone is required')
        ),
        is_email()
    )

    phone: str = field_validated(
        custom(
            at_least_one_required(['email', 'phone'], 'Either email or phone is required')
        ),
        is_phone()
    )

# Valid usage - email only
contact1 = ContactFormModel(
    name='John Doe',
    email='john@example.com'
)

# Valid usage - phone only
contact2 = ContactFormModel(
    name='Jane Doe',
    phone='3001234567'
)

# Valid usage - both
contact3 = ContactFormModel(
    name='Bob Smith',
    email='bob@example.com',
    phone='3009876543'
)

Debug and Development Tools

def debug_conditional_validator(validator_func: Callable[[Any, Optional[Dict[str, Any]]], bool], debug_name: str) -> Callable[[Any, Optional[Dict[str, Any]]], bool]:
    '''
    Wrapper that adds debug information to conditional validators

    Args:
        validator_func (Callable[[Any, Optional[Dict[str, Any]]], bool]): Validator function to wrap
        debug_name (str): Name for debugging purposes

    Returns:
        Callable[[Any, Optional[Dict[str, Any]]], bool]: Wrapped validator function
    '''
    def wrapper(value, context=None):
        import time
        start_time = time.time()

        try:
            result = validator_func(value, context)
            end_time = time.time()

            if hasattr(wrapper, '__debug_mode__') and wrapper.__debug_mode__:
                print(f'[DEBUG] {debug_name}: {result} (took {(end_time - start_time)*1000:.2f}ms)')

            return result
        except Exception as e:
            end_time = time.time()
            debug_info = {
                'validator_name': debug_name,
                'validation_time_ms': round((end_time - start_time) * 1000, 2),
                'exception': str(e),
                'input_value': str(value)[:100],
                'context': str(context)[:200] if context else None
            }
            wrapper.__debug_info__ = debug_info
            return False

    wrapper.__message__ = getattr(validator_func, '__message__', 'Validation failed')
    return wrapper

# Usage in development
class DebugModel(ValidatedModel):
    field1: str = field_validated(is_required())
    field2: str = field_validated(
        custom(
            debug_conditional_validator(
                required_if('field1', 'special_value'),
                'required_if_debug'
            )
        )
    )

Systematic Testing

import pytest

def test_conditional_validation() -> None:
    '''
    Comprehensive test for conditional validation

    1. Condition not met, field optional
    2. Condition met, required field present
    3. Condition met, required field missing
    '''

    # Case 1: Condition not met, field optional
    model1 = PaymentModel(payment_method='cash')
    assert model1.payment_method == 'cash'

    # Case 2: Condition met, required field present
    model2 = PaymentModel(
        payment_method='credit_card',
        card_number='1234-5678-9012-3456'
    )
    assert model2.card_number is not None

    # Case 3: Condition met, required field missing
    with pytest.raises(ValidationException) as exc_info:
        PaymentModel(payment_method='credit_card')

    assert 'card_number' in exc_info.value.validations

# Run: pytest test_conditional.py -v

Conditional validation is a powerful tool that allows creating complex and flexible business rules, adapting to different scenarios based on data context.