Repository: getredash/redash

Issue:
Number-based alerts are broken
<!--

We use GitHub only for bug reports 🐛

Anything else should be posted to https://discuss.redash.io 👫

🚨For support, help & questions use https://discuss.redash.io/c/support
💡For feature requests & ideas use https://discuss.redash.io/c/feature-requests

**Found a security vulnerability?** Please email security@redash.io to report any security vulnerabilities. We will acknowledge receipt of your vulnerability and strive to send you regular updates about our progress. If you're curious about the status of your disclosure please feel free to email us again. If you want to encrypt your disclosure email, you can use this PGP key.

-->

### Issue Summary
It looks like the alert always sees a query result as a string and not a number. So an alert configured to trigger when `field = 0` never triggers because `"0"` does not equal `0`.

The alerts seems to evaluate fine for targets that are obviously strings. If the alert should trigger if `field = boyhowdy` then the alert triggers when `field` is "boyhowdy".


### Steps to Reproduce

1. Save a query that returns 0: `SELECT 0 "value"`
2. Make an alert for the query that triggers when `value = 0`
3. Run the query
4. Observe the alert does not trigger.

The converse is also true, by the way. 

1. Same as above
2. Make an alert for the query that triggers when `value != 0`
3. Run the query
4. Observe the alert triggers

### Technical details:

* Redash Version: 9.0.0-alpha+b348b58b (348b58b) 
* Browser/OS: Firefox on MacOS
* How did you install Redash: SaaS



Discussion:
I believe this is happens cause the threshold input field has been changed to accept texts (to accommodate text comparisons) so all new alerts yield a string from the threshold value.

@rauchy @kravets-levko wdyt of handling this in the backend - casting both args to string for `==`/`!=` and to int for the rest?
https://github.com/getredash/redash/blob/5d585036239e40973adebceadc0dfcceb82e2250/redash/models/__init__.py#L826-L847
I think the solution could be something like: if alert's threshold value contains numeric string and query result's column type is number - cast both to number; otherwise cast both to string:

| Query result column type | Alert threshold value | Cast both to |
|--|--|--|
| number | string containing number | number |
| number | non-numeric string | string |
| other types | string containing number | string |
| other types | non-numeric string | string |
I think that if query result is a number, then we should try to cast both to number. In other cases: use a string.

Because the operation options we show are based on the query result type, the threshold should adhere. We might want to enforce this in the UI.
Not sure I understand why casting-by-value would be preferable to casting-by-operation.
> Not sure I understand why casting-by-value would be preferable to casting-by-operation.

Because strings and numbers have different behavior, for example:

In case of a number `0.0` == `0`, but in case of strings `'0.0'` != `'0'`.
Aha. So I propose the following:

|Operator| Cast  |
|--|--|
|== / !=|Threshold to result value type |
| Other | Both to number |
Not sure about this, but perhaps we can hint at string values with apostrophes.

<img width="298" alt="Screen Shot 2019-10-23 at 10 35 10" src="https://user-images.githubusercontent.com/486954/67369094-16959980-f581-11e9-86c1-02ea353603a3.png">

@susodapop reopening as the fix wasn't pushed to this repo yet.

Resolving patch:
diff --git a/redash/models/__init__.py b/redash/models/__init__.py
--- a/redash/models/__init__.py
+++ b/redash/models/__init__.py
@@ -2,6 +2,7 @@
 import calendar
 import logging
 import time
+import numbers
 import pytz
 
 from six import text_type
@@ -775,6 +776,43 @@ def are_favorites(cls, user, objects):
         return [fav.object_id for fav in cls.query.filter(cls.object_id.in_([o.id for o in objects]), cls.object_type == object_type, cls.user_id == user)]
 
 
+OPERATORS = {
+    '>': lambda v, t: v > t,
+    '>=': lambda v, t: v >= t,
+    '<': lambda v, t: v < t,
+    '<=': lambda v, t: v <= t,
+    '==': lambda v, t: v == t,
+    '!=': lambda v, t: v != t,
+
+    # backward compatibility
+    'greater than': lambda v, t: v > t,
+    'less than': lambda v, t: v < t,
+    'equals': lambda v, t: v == t,
+}
+
+
+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):
+        value = str(value).lower()
+    else:
+        value = str(value)
+
+    if op(value, threshold):
+        new_state = Alert.TRIGGERED_STATE
+    else:
+        new_state = Alert.OK_STATE
+    
+    return new_state
+
+
 @generic_repr('id', 'name', 'query_id', 'user_id', 'state', 'last_triggered_at', 'rearm')
 class Alert(TimestampMixin, BelongsToOrgMixin, db.Model):
     UNKNOWN_STATE = 'unknown'
@@ -819,28 +857,12 @@ def evaluate(self):
         data = self.query_rel.latest_query_data.data
 
         if data['rows'] and self.options['column'] in data['rows'][0]:
-            operators = {
-                '>': lambda v, t: v > t,
-                '>=': lambda v, t: v >= t,
-                '<': lambda v, t: v < t,
-                '<=': lambda v, t: v <= t,
-                '==': lambda v, t: v == t,
-                '!=': lambda v, t: v != t,
-
-                # backward compatibility
-                'greater than': lambda v, t: v > t,
-                'less than': lambda v, t: v < t,
-                'equals': lambda v, t: v == t,
-            }
-            should_trigger = operators.get(self.options['op'], lambda v, t: False)
+            op = OPERATORS.get(self.options['op'], lambda v, t: False)
 
             value = data['rows'][0][self.options['column']]
             threshold = self.options['value']
 
-            if should_trigger(value, threshold):
-                new_state = self.TRIGGERED_STATE
-            else:
-                new_state = self.OK_STATE
+            new_state = next_state(op, value, threshold)
         else:
             new_state = self.UNKNOWN_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
@@ -1,5 +1,6 @@
+from unittest import TestCase
 from tests import BaseTestCase
-from redash.models import Alert, db
+from redash.models import Alert, db, next_state, OPERATORS
 from redash.utils import json_dumps
 
 
@@ -44,16 +45,20 @@ def get_results(value):
 
 
 class TestAlertEvaluate(BaseTestCase):
-    def create_alert(self, results, column='foo'):
+    def create_alert(self, results, column='foo', value="1"):
         result = self.factory.create_query_result(data=results)
         query = self.factory.create_query(latest_query_data_id=result.id)
-        alert = self.factory.create_alert(query_rel=query, options={'op': 'equals', 'column': column, 'value': 1})
+        alert = self.factory.create_alert(query_rel=query, options={'op': 'equals', 'column': column, 'value': value})
         return alert
 
     def test_evaluate_triggers_alert_when_equal(self):
         alert = self.create_alert(get_results(1))
         self.assertEqual(alert.evaluate(), Alert.TRIGGERED_STATE)
 
+    def test_evaluate_number_value_and_string_threshold(self):
+        alert = self.create_alert(get_results(1), value="string")
+        self.assertEqual(alert.evaluate(), Alert.UNKNOWN_STATE)
+
     def test_evaluate_return_unknown_when_missing_column(self):
         alert = self.create_alert(get_results(1), column='bar')
         self.assertEqual(alert.evaluate(), Alert.UNKNOWN_STATE)
@@ -61,4 +66,23 @@ def test_evaluate_return_unknown_when_missing_column(self):
     def test_evaluate_return_unknown_when_empty_results(self):
         results = json_dumps({'rows': [], 'columns': [{'name': 'foo', 'type': 'STRING'}]})
         alert = self.create_alert(results)
-        self.assertEqual(alert.evaluate(), Alert.UNKNOWN_STATE)
\ No newline at end of file
+        self.assertEqual(alert.evaluate(), Alert.UNKNOWN_STATE)
+
+
+class TestNextState(TestCase):
+    def test_numeric_value(self):
+        self.assertEqual(Alert.TRIGGERED_STATE, next_state(OPERATORS.get('=='), 1, "1"))
+        self.assertEqual(Alert.TRIGGERED_STATE, next_state(OPERATORS.get('=='), 1, "1.0"))
+    
+    def test_numeric_value_and_plain_string(self):
+        self.assertEqual(Alert.UNKNOWN_STATE, next_state(OPERATORS.get('=='), 1, "string"))
+
+    def test_non_numeric_value(self):
+        self.assertEqual(Alert.OK_STATE, next_state(OPERATORS.get('=='), "1", "1.0"))
+
+    def test_string_value(self):
+        self.assertEqual(Alert.TRIGGERED_STATE, next_state(OPERATORS.get('=='), "string", "string"))
+    
+    def test_boolean_value(self):
+        self.assertEqual(Alert.TRIGGERED_STATE, next_state(OPERATORS.get('=='), False, 'false'))
+        self.assertEqual(Alert.TRIGGERED_STATE, next_state(OPERATORS.get('!='), False, 'true'))
\ No newline at end of file