Repository: codingedward/flask-sieve

Issue:
Equivalent of "sometimes"
Is there an equivalent of "sometimes" from Laravel validation? i.e. only validate the value if present and non-empty, otherwise skip the validation.

Comments:

Patch:
diff --git a/docs/source/index.md b/docs/source/index.md
index 8a4e9ce..cad4271 100644
--- a/docs/source/index.md
+++ b/docs/source/index.md
@@ -580,6 +580,10 @@ The given _field_ must match the field under validation.
 
 The field under validation must have a size matching the given _value_. For string data, _value_ corresponds to the number of characters. For numeric data, _value_ corresponds to a given integer value. For an array, _size_ corresponds to the `count` of the array. For files, _size_ corresponds to the file size in kilobytes.
 
+#### sometimes
+
+The other validations will only apply if this field is present and non-empty. Incompatible with _required_ and _nullable_.
+
 #### starts_with:_foo_,_bar_,...
 
 The field under validation must start with one of the given values.
diff --git a/flask_sieve/lang/en.py b/flask_sieve/lang/en.py
index 5ba658f..ac34fb2 100644
--- a/flask_sieve/lang/en.py
+++ b/flask_sieve/lang/en.py
@@ -54,18 +54,21 @@ rule_messages = {
         'file': 'The :attribute must be less than :value_0 kilobytes.',
         'string': 'The :attribute must be less than :value_0 characters.',
         'array': 'The :attribute must have less than :value_0 items.',
+        'empty': 'The :attribute could not be validated since it is empty.'
     },
     'lte': {
         'numeric': 'The :attribute must be less than or equal :value_0.',
         'file': 'The :attribute must be less than or equal :value_0 kilobytes.',
         'string': 'The :attribute must be less than or equal :value_0 characters.',
         'array': 'The :attribute must not have more than :value_0 items.',
+        'empty': 'The :attribute could not be validated since it is empty.'
     },
     'max': {
         'numeric': 'The :attribute may not be greater than :max_0.',
         'file': 'The :attribute may not be greater than :max_0 kilobytes.',
         'string': 'The :attribute may not be greater than :max_0 characters.',
         'array': 'The :attribute may not have more than :max_0 items.',
+        'empty': 'The :attribute could not be validated since it is empty.'
     },
     'mime_types': 'The :attribute must be a file of type: :values_0.',
     'min': {
@@ -73,6 +76,7 @@ rule_messages = {
         'file': 'The :attribute must be at least :min_0 kilobytes.',
         'string': 'The :attribute must be at least :min_0 characters.',
         'array': 'The :attribute must have at least :min_0 items.',
+        'empty': 'The :attribute could not be validated since it is empty.'
     },
     'not_in': 'The selected :attribute is invalid.',
     'not_regex': 'The :attribute format is invalid.',
diff --git a/flask_sieve/rules_processor.py b/flask_sieve/rules_processor.py
index d6f58b2..1fee1ba 100644
--- a/flask_sieve/rules_processor.py
+++ b/flask_sieve/rules_processor.py
@@ -246,6 +246,10 @@ class RulesProcessor:
     def validate_file(value, **kwargs):
         return isinstance(value, FileStorage)
 
+    @staticmethod
+    def validate_empty(value, **kwargs):
+        return value == ''
+
     def validate_filled(self, value, attribute, nullable, **kwargs):
         if self.validate_present(attribute):
             return self.validate_required(value, attribute, nullable)
@@ -364,18 +368,24 @@ class RulesProcessor:
 
     def validate_lt(self, value, params, **kwargs):
         self._assert_params_size(size=1, params=params, rule='lt')
+        if value == '':
+            return False
         value = self._get_size(value)
         lower = self._get_size(self._attribute_value(params[0]))
         return value < lower
 
     def validate_lte(self, value, params, **kwargs):
         self._assert_params_size(size=1, params=params, rule='lte')
+        if value == '':
+            return False
         value = self._get_size(value)
         lower = self._get_size(self._attribute_value(params[0]))
         return value <= lower
 
     def validate_max(self, value, params, **kwargs):
         self._assert_params_size(size=1, params=params, rule='max')
+        if value == '':
+            return False
         value = self._get_size(value)
         upper = self._get_size(params[0])
         return value <= upper
@@ -406,6 +416,9 @@ class RulesProcessor:
     def validate_nullable(value, **kwargs):
         return True
 
+    def validate_sometimes(self, value, **kwargs):
+        return True
+
     def validate_numeric(self, value, **kwargs):
         return self._can_call_with_method(float, value)
 
@@ -527,14 +540,16 @@ class RulesProcessor:
             r'^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$',
             str(value).lower()
         ) is not None
-    
+
     def _is_attribute_nullable(self, attribute, params, rules, **kwargs):
         is_explicitly_nullable = self._has_rule(rules, 'nullable')
         if is_explicitly_nullable:
             return True
         value = self._attribute_value(attribute)
-        if value is not None:
-            return False
+        is_optional = self._has_rule(rules, 'sometimes')
+        if is_optional and value is not None:
+            return True
+
         attribute_conditional_rules = list(filter(lambda rule: rule['name'] in conditional_inclusion_rules, rules))
         if len(attribute_conditional_rules) == 0:
             return False
@@ -621,6 +636,8 @@ class RulesProcessor:
             return 'array'
         elif self.validate_file(value):
             return 'file'
+        elif self.validate_empty(value):
+            return 'empty'
         return 'string'
 
     def _has_any_of_rules(self, subset, rules):


Test patch:
diff --git a/tests/test_validator.py b/tests/test_validator.py
index d41ebd9..fecfe45 100644
--- a/tests/test_validator.py
+++ b/tests/test_validator.py
@@ -163,6 +163,17 @@ class TestValidator(unittest.TestCase):
         )
         self.assertTrue(self._validator.passes())
 
+    def test_translates_validations_set_through_custom_handlers(self):
+        def validate_odd(value, **kwargs):
+            return int(value) % 2
+        self._validator.set_custom_handlers([
+            {
+                'handler': validate_odd,
+                'message':'This number must be odd.',
+                'params_count':0
+            }
+        ])
+
     def test_cannot_set_custom_handler_without_validate_keyword(self):
         def method_odd(value, **kwargs):
             return int(value) % 2
@@ -173,9 +184,43 @@ class TestValidator(unittest.TestCase):
                 params_count=0
             )
 
+    def test_sometimes_request(self):
+        self.set_validator_params(
+            rules={'number': ['sometimes', 'max:5']},
+            request={}
+        )
+        self.assertTrue(self._validator.passes())
+
+        self.set_validator_params(
+            rules={'number': ['sometimes', 'max:5']},
+            request={'number': ''}
+        )
+        self.assertTrue(self._validator.fails())
+        self.assertDictEqual({
+            'number': [
+                'The number could not be validated since it is empty.'
+            ]
+        }, self._validator.messages())
+
+        self.set_validator_params(
+            rules={'number': ['sometimes', 'max:5']},
+            request={'number': 2}
+        )
+        self.assertTrue(self._validator.passes())
+
+        self.set_validator_params(
+            rules={'number': ['sometimes', 'max:5']},
+            request={'number': 10}
+        )
+        self.assertTrue(self._validator.fails())
+        self.assertDictEqual({
+            'number': [
+                'The number may not be greater than 5.'
+            ]
+        }, self._validator.messages())
+
     def set_validator_params(self, rules=None, request=None):
         rules = rules or {}
         request = request or {}
         self._validator.set_rules(rules)
         self._validator.set_request(request)
-