Browse Source

feat(api): Add numeric filtering to search parser. Add key remapper.

We need numeric filtering for the `times_seen` property on groups, and potentially other areas.

Added the ability to remap keys to standard names, since we have legacy names passed in issue
search.
Dan Fuller 6 years ago
parent
commit
cd5b04b2b6
2 changed files with 85 additions and 4 deletions
  1. 32 2
      src/sentry/api/event_search.py
  2. 53 2
      tests/sentry/api/test_event_search.py

+ 32 - 2
src/sentry/api/event_search.py

@@ -4,6 +4,7 @@ import re
 import six
 
 from collections import namedtuple
+from django.utils.functional import cached_property
 from funcy.seqs import flatten
 from funcy.types import is_list
 
@@ -63,7 +64,9 @@ event_search_grammar = Grammar(r"""
 # raw_search must come at the end, otherwise other
 # search_terms will be treated as a raw query
 search          = search_term* raw_search?
-search_term     = space? (time_filter / rel_time_filter / specific_time_filter / has_filter / is_filter / basic_filter) space?
+search_term     = space? (time_filter / rel_time_filter / specific_time_filter
+                  / numeric_filter / has_filter / is_filter / basic_filter)
+                  space?
 raw_search      = ~r".+$"
 
 # standard key:val filter
@@ -74,6 +77,8 @@ time_filter     = search_key sep? operator date_format
 rel_time_filter = search_key sep rel_date_format
 # exact time filter for dates
 specific_time_filter = search_key sep date_format
+# Numeric comparison filter
+numeric_filter  = search_key sep operator ~r"[0-9]+"
 
 # has filter for not null type checks
 has_filter      = negation? "has" sep (search_key / search_value)
@@ -142,9 +147,20 @@ class SearchValue(namedtuple('SearchValue', 'raw_value')):
 
 
 class SearchVisitor(NodeVisitor):
+    # A list of mappers that map source keys to a target name. Format is
+    # <target_name>: [<list of source names>],
+    key_mappings = {}
 
     unwrapped_exceptions = (InvalidSearchQuery,)
 
+    @cached_property
+    def key_mappings_lookup(self):
+        lookup = {}
+        for target_field, source_fields in self.key_mappings.items():
+            for source_field in source_fields:
+                lookup[source_field] = target_field
+        return lookup
+
     def visit_search(self, node, children):
         # there is a list from search_term and one from raw_search, so flatten them.
         # Flatten each group in the list, since nodes can return multiple items
@@ -163,6 +179,19 @@ class SearchVisitor(NodeVisitor):
             SearchValue(node.text),
         )
 
+    def visit_numeric_filter(self, node, children):
+        search_key, _, operator, search_value = children
+        try:
+            search_value = int(search_value.text)
+        except ValueError:
+            raise InvalidSearchQuery('Invalid numeric query: %s' % (search_key,))
+
+        return SearchFilter(
+            search_key,
+            operator,
+            SearchValue(search_value),
+        )
+
     def visit_time_filter(self, node, children):
         search_key, _, operator, search_value = children
         try:
@@ -265,7 +294,8 @@ class SearchVisitor(NodeVisitor):
         raise InvalidSearchQuery('"is" queries are not supported on this search')
 
     def visit_search_key(self, node, children):
-        return SearchKey(children[0])
+        key = children[0]
+        return SearchKey(self.key_mappings_lookup.get(key, key))
 
     def visit_search_value(self, node, children):
         return SearchValue(children[0])

+ 53 - 2
tests/sentry/api/test_event_search.py

@@ -8,8 +8,9 @@ from freezegun import freeze_time
 from parsimonious.exceptions import IncompleteParseError
 
 from sentry.api.event_search import (
-    convert_endpoint_params, get_snuba_query_args, parse_search_query,
-    InvalidSearchQuery, SearchFilter, SearchKey, SearchValue
+    convert_endpoint_params, event_search_grammar, get_snuba_query_args,
+    parse_search_query, InvalidSearchQuery, SearchFilter, SearchKey,
+    SearchValue, SearchVisitor,
 )
 from sentry.testutils import TestCase
 
@@ -360,6 +361,56 @@ class ParseSearchQueryTest(TestCase):
         with self.assertRaises(InvalidSearchQuery):
             parse_search_query('is:unassigned')
 
+    def test_key_remapping(self):
+        class RemapVisitor(SearchVisitor):
+            key_mappings = {
+                'target_value': ['someValue', 'legacy-value'],
+            }
+
+        tree = event_search_grammar.parse('someValue:123 legacy-value:456 normal_value:hello')
+        assert RemapVisitor().visit(tree) == [
+            SearchFilter(
+                key=SearchKey(name='target_value'),
+                operator='=',
+                value=SearchValue('123'),
+            ),
+            SearchFilter(
+                key=SearchKey(name='target_value'),
+                operator='=',
+                value=SearchValue('456'),
+            ),
+            SearchFilter(
+                key=SearchKey(name='normal_value'),
+                operator='=',
+                value=SearchValue('hello'),
+            ),
+        ]
+
+    def test_numeric_filter(self):
+        # test numeric format
+        assert parse_search_query('some_number:>500') == [
+            SearchFilter(
+                key=SearchKey(name='some_number'),
+                operator=">",
+                value=SearchValue(raw_value=500),
+            ),
+        ]
+        assert parse_search_query('some_number:<500') == [
+            SearchFilter(
+                key=SearchKey(name='some_number'),
+                operator="<",
+                value=SearchValue(raw_value=500),
+            ),
+        ]
+        # Non numeric shouldn't match
+        assert parse_search_query('some_number:<hello') == [
+            SearchFilter(
+                key=SearchKey(name='some_number'),
+                operator="=",
+                value=SearchValue(raw_value="<hello"),
+            ),
+        ]
+
 
 class GetSnubaQueryArgsTest(TestCase):
     def test_simple(self):