Repository: getredash/redash

Issue:
Alerts: possible issue when query result value is a string number
Some data sources return only strings, even when the value is a number. One such example is Google Analytics. In this case, it's possible that your value will be `"600"` (i.e. the text representation of 600). Here's some possible scenarios:

* Newly created alerts, will have the threshold as a string. Both values will be compares as strings, and while it won't fail and it will return the wrong alert state.
* For alerts created before the alert page update, the threshold value would be a number. In Python 2, if you compared a number and a string it would be the same as comparing two strings. But in Python 3 it just fails (raises an exception).

I wonder what should we do? Should we cast values to numbers whenever we use the greater than/less than operators? Should we catch the error and ignore it?


Discussion:
> Should we cast values to numbers whenever we use the greater than/less than operators

If string comparisons with those operators never existed/worked before, I think this is a safe way to go. Conceptually, this will make the "<" and ">" operators be associated with Number values, if at some point we decide to add new operators for strings, it's a matter of adding a toggle in UI to disable the casting.

Anyway, I assume the majority of Alert operations are intended to happen as number operations, so this feels like the option that should allow more use cases.
For me it looks good if "<" and ">" will work only for numbers (and will cast both operands to number), and "=="/"!=" will work both for string and numbers (cast to type of results column?).

Resolving patch:
diff --git a/redash/models/__init__.py b/redash/models/__init__.py
--- a/redash/models/__init__.py
+++ b/redash/models/__init__.py
@@ -909,18 +909,25 @@ def are_favorites(cls, user, objects):
 
 
 def next_state(op, value, threshold):
-    if isinstance(value, numbers.Number) and not isinstance(value, bool):
-        try:
-            threshold = float(threshold)
-        except ValueError:
-            return Alert.UNKNOWN_STATE
-    # If it's a boolean cast to string and lower case, because upper cased
-    # boolean value is Python specific and most likely will be confusing to
-    # users.
-    elif isinstance(value, bool):
+    if isinstance(value, bool):
+        # If it's a boolean cast to string and lower case, because upper cased
+        # boolean value is Python specific and most likely will be confusing to
+        # users.
         value = str(value).lower()
     else:
-        value = str(value)
+        try:
+            value = float(value)
+            value_is_number = True
+        except ValueError:
+            value_is_number = isinstance(value, numbers.Number)
+
+        if value_is_number:
+            try:
+                threshold = float(threshold)
+            except ValueError:
+                return Alert.UNKNOWN_STATE
+        else:
+            value = str(value)
 
     if op(value, threshold):
         new_state = Alert.TRIGGERED_STATE


Test patch:
diff --git a/tests/models/test_alerts.py b/tests/models/test_alerts.py
--- a/tests/models/test_alerts.py
+++ b/tests/models/test_alerts.py
@@ -81,6 +81,7 @@ def test_numeric_value(self):
         self.assertEqual(
             Alert.TRIGGERED_STATE, next_state(OPERATORS.get("=="), 1, "1.0")
         )
+        self.assertEqual(Alert.TRIGGERED_STATE, next_state(OPERATORS.get(">"), "5", 1))
 
     def test_numeric_value_and_plain_string(self):
         self.assertEqual(
@@ -88,7 +89,7 @@ def test_numeric_value_and_plain_string(self):
         )
 
     def test_non_numeric_value(self):
-        self.assertEqual(Alert.OK_STATE, next_state(OPERATORS.get("=="), "1", "1.0"))
+        self.assertEqual(Alert.OK_STATE, next_state(OPERATORS.get("=="), "string", "1.0"))
 
     def test_string_value(self):
         self.assertEqual(