Browse Source

feat(discover-snql): Add support for boolean search (#27872)

This change introduces support for boolean search using AND/OR and parenthesis
in snql.
Tony Xiao 3 years ago
parent
commit
eddc6ff020

+ 21 - 4
src/sentry/search/events/builder.py

@@ -4,6 +4,7 @@ from snuba_sdk.entity import Entity
 from snuba_sdk.expressions import Limit
 from snuba_sdk.query import Query
 
+from sentry.search.events.fields import InvalidSearchQuery
 from sentry.search.events.filter import QueryFilter
 from sentry.search.events.types import ParamsType, SelectType
 from sentry.utils.snuba import Dataset
@@ -19,17 +20,19 @@ class QueryBuilder(QueryFilter):
         query: Optional[str] = None,
         selected_columns: Optional[List[str]] = None,
         orderby: Optional[List[str]] = None,
+        auto_aggregations: bool = False,
         use_aggregate_conditions: bool = False,
         limit: int = 50,
     ):
         super().__init__(dataset, params)
 
+        # TODO: implement this in `resolve_select`
+        self.auto_aggregations = auto_aggregations
+
         self.limit = Limit(limit)
 
-        parsed_terms = self.parse_query(query)
-        self.where = self.resolve_where(parsed_terms)
-        self.having = self.resolve_having(
-            parsed_terms, use_aggregate_conditions=use_aggregate_conditions
+        self.where, self.having = self.resolve_conditions(
+            query, use_aggregate_conditions=use_aggregate_conditions
         )
 
         # params depends on parse_query, and conditions being resolved first since there may be projects in conditions
@@ -49,7 +52,21 @@ class QueryBuilder(QueryFilter):
         else:
             return []
 
+    def validate_having_clause(self):
+        error_extra = ", and could not be automatically added" if self.auto_aggregations else ""
+        for condition in self.having:
+            lhs = condition.lhs
+            if lhs not in self.columns:
+                raise InvalidSearchQuery(
+                    "Aggregate {} used in a condition but is not a selected column{}.".format(
+                        lhs.alias,
+                        error_extra,
+                    )
+                )
+
     def get_snql_query(self) -> Query:
+        self.validate_having_clause()
+
         return Query(
             dataset=self.dataset.value,
             match=Entity(self.dataset.value),

+ 117 - 9
src/sentry/search/events/filter.py

@@ -4,7 +4,7 @@ from typing import Callable, List, Mapping, Optional, Sequence, Tuple, Union
 from parsimonious.exceptions import ParseError
 from sentry_relay import parse_release as parse_release_relay
 from sentry_relay.consts import SPAN_STATUS_NAME_TO_CODE
-from snuba_sdk.conditions import Condition, Op, Or
+from snuba_sdk.conditions import And, Condition, Op, Or
 from snuba_sdk.function import Function
 
 from sentry import eventstore
@@ -1024,7 +1024,8 @@ def format_search_filter(term, params):
 
 
 # Not a part of search.events.types to avoid a circular loop
-ParsedTerms = Sequence[Union[SearchFilter, AggregateFilter]]
+ParsedTerm = Union[SearchFilter, AggregateFilter]
+ParsedTerms = Sequence[ParsedTerm]
 
 
 class QueryFilter(QueryFields):
@@ -1059,8 +1060,120 @@ class QueryFilter(QueryFields):
         except ParseError as e:
             raise InvalidSearchQuery(f"Parse error: {e.expr.name} (column {e.column():d})")
 
+        if not parsed_terms:
+            return []
+
         return parsed_terms
 
+    def resolve_conditions(
+        self,
+        query: Optional[str],
+        use_aggregate_conditions: bool,
+    ) -> Tuple[List[WhereType], List[WhereType]]:
+        parsed_terms = self.parse_query(query)
+
+        if any(
+            isinstance(term, ParenExpression) or SearchBoolean.is_operator(term)
+            for term in parsed_terms
+        ):
+            where, having = self.resolve_boolean_conditions(parsed_terms)
+            if not use_aggregate_conditions:
+                having = []
+        else:
+            where = self.resolve_where(parsed_terms)
+            having = self.resolve_having(parsed_terms) if use_aggregate_conditions else []
+        return where, having
+
+    def resolve_boolean_conditions(
+        self, terms: ParsedTerms
+    ) -> Tuple[List[WhereType], List[WhereType]]:
+        if len(terms) == 1:
+            return self.resolve_boolean_condition(terms[0])
+
+        # Filter out any ANDs since we can assume anything without an OR is an AND. Also do some
+        # basic sanitization of the query: can't have two operators next to each other, and can't
+        # start or end a query with an operator.
+        prev = None
+        new_terms = []
+        for term in terms:
+            if prev:
+                if SearchBoolean.is_operator(prev) and SearchBoolean.is_operator(term):
+                    raise InvalidSearchQuery(
+                        f"Missing condition in between two condition operators: '{prev} {term}'"
+                    )
+            else:
+                if SearchBoolean.is_operator(term):
+                    raise InvalidSearchQuery(
+                        f"Condition is missing on the left side of '{term}' operator"
+                    )
+
+            if term != SearchBoolean.BOOLEAN_AND:
+                new_terms.append(term)
+
+            prev = term
+
+        if SearchBoolean.is_operator(term):
+            raise InvalidSearchQuery(f"Condition is missing on the right side of '{term}' operator")
+        terms = new_terms
+
+        # We put precedence on AND, which sort of counter-intuitively means we have to split the query
+        # on ORs first, so the ANDs are grouped together. Search through the query for ORs and split the
+        # query on each OR.
+        # We want to maintain a binary tree, so split the terms on the first OR we can find and recurse on
+        # the two sides. If there is no OR, split the first element out to AND
+        index = None
+        lhs, rhs = None, None
+        operator = None
+        try:
+            index = terms.index(SearchBoolean.BOOLEAN_OR)
+            lhs, rhs = terms[:index], terms[index + 1 :]
+            operator = Or
+        except Exception:
+            lhs, rhs = terms[:1], terms[1:]
+            operator = And
+
+        lhs_where, lhs_having = self.resolve_boolean_conditions(lhs)
+        rhs_where, rhs_having = self.resolve_boolean_conditions(rhs)
+
+        if operator == Or and (lhs_where or rhs_where) and (lhs_having or rhs_having):
+            raise InvalidSearchQuery(
+                "Having an OR between aggregate filters and normal filters is invalid."
+            )
+
+        where = self._combine_conditions(lhs_where, rhs_where, operator)
+        having = self._combine_conditions(lhs_having, rhs_having, operator)
+
+        return where, having
+
+    def _combine_conditions(self, lhs, rhs, operator):
+        combined_conditions = [
+            conditions[0] if len(conditions) == 1 else And(conditions=conditions)
+            for conditions in [lhs, rhs]
+            if len(conditions) > 0
+        ]
+        length = len(combined_conditions)
+        if length == 0:
+            return []
+        elif len(combined_conditions) == 1:
+            return combined_conditions
+        else:
+            return [operator(conditions=combined_conditions)]
+
+    def resolve_boolean_condition(
+        self, term: ParsedTerm
+    ) -> Tuple[List[WhereType], List[WhereType]]:
+        if isinstance(term, ParenExpression):
+            return self.resolve_boolean_conditions(term.children)
+
+        where, having = [], []
+
+        if isinstance(term, SearchFilter):
+            where = self.resolve_where([term])
+        elif isinstance(term, AggregateFilter):
+            having = self.resolve_having([term])
+
+        return where, having
+
     def resolve_where(self, parsed_terms: ParsedTerms) -> List[WhereType]:
         """Given a list of parsed terms, construct their equivalent snql where
         conditions. filtering out any aggregates"""
@@ -1073,13 +1186,9 @@ class QueryFilter(QueryFields):
 
         return where_conditions
 
-    def resolve_having(
-        self, parsed_terms: ParsedTerms, use_aggregate_conditions: bool = False
-    ) -> List[WhereType]:
+    def resolve_having(self, parsed_terms: ParsedTerms) -> List[WhereType]:
         """Given a list of parsed terms, construct their equivalent snql having
         conditions, filtering only for aggregate conditions"""
-        if not use_aggregate_conditions:
-            return []
 
         having_conditions: List[WhereType] = []
         for term in parsed_terms:
@@ -1113,8 +1222,7 @@ class QueryFilter(QueryFields):
         conditions.append(Condition(self.column("timestamp"), Op.GTE, start))
         conditions.append(Condition(self.column("timestamp"), Op.LT, end))
 
-        # If we already have projects_to_filter, there's no need to add an additional project filter
-        if "project_id" in self.params and len(self.projects_to_filter) == 0:
+        if "project_id" in self.params:
             conditions.append(
                 Condition(
                     self.column("project_id"),

+ 1 - 0
src/sentry/snuba/discover.py

@@ -231,6 +231,7 @@ def query(
             query=query,
             selected_columns=selected_columns,
             orderby=orderby,
+            auto_aggregations=auto_aggregations,
             use_aggregate_conditions=use_aggregate_conditions,
             limit=limit,
         )

+ 6 - 0
tests/sentry/search/events/test_builder.py

@@ -162,9 +162,12 @@ class QueryBuilderTest(TestCase):
         self.assertCountEqual(
             query.where,
             [
+                # generated by the search query on project
                 Condition(Column("project_id"), Op.EQ, project1.id),
                 Condition(Column("timestamp"), Op.GTE, self.start),
                 Condition(Column("timestamp"), Op.LT, self.end),
+                # default project filter from the params
+                Condition(Column("project_id"), Op.IN, [project1.id, project2.id]),
             ],
         )
 
@@ -229,9 +232,12 @@ class QueryBuilderTest(TestCase):
         self.assertCountEqual(
             query.where,
             [
+                # generated by the search query on project
                 Condition(Column("project_id"), Op.EQ, project1.id),
                 Condition(Column("timestamp"), Op.GTE, self.start),
                 Condition(Column("timestamp"), Op.LT, self.end),
+                # default project filter from the params
+                Condition(Column("project_id"), Op.IN, [project1.id, project2.id]),
             ],
         )
         # Because of the condition on project there should only be 1 project in the transform

+ 878 - 1
tests/sentry/search/events/test_filter.py

@@ -6,6 +6,9 @@ from unittest.mock import patch
 import pytest
 from django.utils import timezone
 from sentry_relay.consts import SPAN_STATUS_CODE_TO_NAME
+from snuba_sdk.column import Column
+from snuba_sdk.conditions import And, Condition, Op, Or
+from snuba_sdk.function import Function
 
 from sentry.api.event_search import SearchFilter, SearchKey, SearchValue
 from sentry.api.release_search import INVALID_SEMVER_MESSAGE
@@ -25,15 +28,17 @@ from sentry.search.events.fields import (
     with_default,
 )
 from sentry.search.events.filter import (
+    QueryFilter,
     _semver_build_filter_converter,
     _semver_filter_converter,
     _semver_package_filter_converter,
     get_filter,
     parse_semver,
 )
+from sentry.search.events.types import ParamsType
 from sentry.testutils.cases import TestCase
 from sentry.testutils.helpers.datetime import before_now
-from sentry.utils.snuba import OPERATOR_TO_FUNCTION
+from sentry.utils.snuba import OPERATOR_TO_FUNCTION, Dataset
 
 
 # Helper functions to make reading the expected output from the boolean tests easier to read. #
@@ -1782,3 +1787,875 @@ class ParseSemverTest(unittest.TestCase):
         self.run_test("1.2.3.*", "=", SemverFilter("exact", [1, 2, 3]))
         self.run_test("sentry@1.2.3.*", "=", SemverFilter("exact", [1, 2, 3], "sentry"))
         self.run_test("1.X", "=", SemverFilter("exact", [1]))
+
+
+def _cond(lhs, op, rhs):
+    return Condition(lhs=Column(name=lhs), op=op, rhs=rhs)
+
+
+def _email(x):
+    return _cond("email", Op.EQ, x)
+
+
+def _message(x):
+    return Condition(
+        lhs=Function("positionCaseInsensitive", [Column("message"), x]), op=Op.NEQ, rhs=0
+    )
+
+
+def _tag(key, value, op=None):
+    if op is None:
+        op = Op.IN if isinstance(value, list) else Op.EQ
+    return Condition(lhs=Function("ifNull", [Column(f"tags[{key}]"), ""]), op=op, rhs=value)
+
+
+def _ntag(key, value):
+    op = Op.NOT_IN if isinstance(value, list) else Op.NEQ
+    return _tag(key, value, op=op)
+
+
+def _count(op, x):
+    return Condition(lhs=Function("count", [], "count"), op=op, rhs=x)
+
+
+def _project(x):
+    return _cond("project_id", Op.EQ, x)
+
+
+@pytest.mark.parametrize(
+    "description,query,expected_where,expected_having",
+    [
+        (
+            "simple_OR_with_2_emails",
+            "user.email:foo@example.com OR user.email:bar@example.com",
+            [Or(conditions=[_email("foo@example.com"), _email("bar@example.com")])],
+            [],
+        ),
+        (
+            "simple_AND_with_2_emails",
+            "user.email:foo@example.com AND user.email:bar@example.com",
+            [And(conditions=[_email("foo@example.com"), _email("bar@example.com")])],
+            [],
+        ),
+        ("message_containing_OR_as_a_substring", "ORder", [_message("ORder")], []),
+        ("message_containing_AND_as_a_substring", "ANDroid", [_message("ANDroid")], []),
+        ("single_email_term", "user.email:foo@example.com", [_email("foo@example.com")], []),
+        (
+            "OR_with_wildcard_array_fields",
+            "error.value:Deadlock* OR !stack.filename:*.py",
+            [
+                Or(
+                    conditions=[
+                        Condition(
+                            lhs=Column("exception_stacks.value"), op=Op.LIKE, rhs="Deadlock%"
+                        ),
+                        Condition(
+                            lhs=Column("exception_frames.filename"), op=Op.NOT_LIKE, rhs="%.py"
+                        ),
+                    ]
+                )
+            ],
+            [],
+        ),
+        (
+            "simple_order_of_operations_with_OR_then_AND",
+            "user.email:foo@example.com OR user.email:bar@example.com AND user.email:foobar@example.com",
+            [
+                Or(
+                    conditions=[
+                        _email("foo@example.com"),
+                        And(conditions=[_email("bar@example.com"), _email("foobar@example.com")]),
+                    ]
+                )
+            ],
+            [],
+        ),
+        (
+            "simple_order_of_operations_with_AND_then_OR",
+            "user.email:foo@example.com AND user.email:bar@example.com OR user.email:foobar@example.com",
+            [
+                Or(
+                    conditions=[
+                        And(conditions=[_email("foo@example.com"), _email("bar@example.com")]),
+                        _email("foobar@example.com"),
+                    ]
+                )
+            ],
+            [],
+        ),
+        (
+            "simple_two_ORs",
+            "user.email:foo@example.com OR user.email:bar@example.com OR user.email:foobar@example.com",
+            [
+                Or(
+                    conditions=[
+                        _email("foo@example.com"),
+                        Or(conditions=[_email("bar@example.com"), _email("foobar@example.com")]),
+                    ]
+                )
+            ],
+            [],
+        ),
+        (
+            "simple_two_ANDs",
+            "user.email:foo@example.com AND user.email:bar@example.com AND user.email:foobar@example.com",
+            [
+                And(
+                    conditions=[
+                        _email("foo@example.com"),
+                        And(conditions=[_email("bar@example.com"), _email("foobar@example.com")]),
+                    ]
+                )
+            ],
+            [],
+        ),
+        (
+            "OR_with_two_ANDs",
+            "user.email:foo@example.com AND user.email:bar@example.com OR user.email:foobar@example.com AND user.email:hello@example.com",
+            [
+                Or(
+                    conditions=[
+                        And(conditions=[_email("foo@example.com"), _email("bar@example.com")]),
+                        And(conditions=[_email("foobar@example.com"), _email("hello@example.com")]),
+                    ]
+                )
+            ],
+            [],
+        ),
+        (
+            "OR_with_nested_ANDs",
+            "user.email:foo@example.com AND user.email:bar@example.com OR user.email:foobar@example.com AND user.email:hello@example.com AND user.email:hi@example.com",
+            [
+                Or(
+                    conditions=[
+                        And(conditions=[_email("foo@example.com"), _email("bar@example.com")]),
+                        And(
+                            conditions=[
+                                _email("foobar@example.com"),
+                                And(
+                                    conditions=[
+                                        _email("hello@example.com"),
+                                        _email("hi@example.com"),
+                                    ]
+                                ),
+                            ]
+                        ),
+                    ]
+                )
+            ],
+            [],
+        ),
+        (
+            "multiple_ORs_with_nested_ANDs",
+            "user.email:foo@example.com AND user.email:bar@example.com OR user.email:foobar@example.com AND user.email:hello@example.com AND user.email:hi@example.com OR user.email:foo@example.com AND user.email:bar@example.com OR user.email:foobar@example.com AND user.email:hello@example.com AND user.email:hi@example.com",
+            [
+                Or(
+                    conditions=[
+                        And(conditions=[_email("foo@example.com"), _email("bar@example.com")]),
+                        Or(
+                            conditions=[
+                                And(
+                                    conditions=[
+                                        _email("foobar@example.com"),
+                                        And(
+                                            conditions=[
+                                                _email("hello@example.com"),
+                                                _email("hi@example.com"),
+                                            ]
+                                        ),
+                                    ]
+                                ),
+                                Or(
+                                    conditions=[
+                                        And(
+                                            conditions=[
+                                                _email("foo@example.com"),
+                                                _email("bar@example.com"),
+                                            ]
+                                        ),
+                                        And(
+                                            conditions=[
+                                                _email("foobar@example.com"),
+                                                And(
+                                                    conditions=[
+                                                        _email("hello@example.com"),
+                                                        _email("hi@example.com"),
+                                                    ]
+                                                ),
+                                            ]
+                                        ),
+                                    ]
+                                ),
+                            ]
+                        ),
+                    ],
+                ),
+            ],
+            [],
+        ),
+        (
+            "simple_AND_with_grouped_conditions",
+            "(event.type:error) AND (stack.in_app:true)",
+            [
+                And(
+                    conditions=[
+                        _cond("type", Op.EQ, "error"),
+                        _cond("exception_frames.in_app", Op.EQ, 1),
+                    ]
+                )
+            ],
+            [],
+        ),
+        (
+            "simple_OR_inside_group",
+            "(user.email:foo@example.com OR user.email:bar@example.com)",
+            [Or(conditions=[_email("foo@example.com"), _email("bar@example.com")])],
+            [],
+        ),
+        (
+            "order_of_operations_with_groups_AND_first_OR_second",
+            "(user.email:foo@example.com OR user.email:bar@example.com) AND user.email:foobar@example.com",
+            [
+                And(
+                    conditions=[
+                        Or(conditions=[_email("foo@example.com"), _email("bar@example.com")]),
+                        _email("foobar@example.com"),
+                    ]
+                )
+            ],
+            [],
+        ),
+        (
+            "order_of_operations_with_groups_AND_first_OR_second",
+            "user.email:foo@example.com AND (user.email:bar@example.com OR user.email:foobar@example.com)",
+            [
+                And(
+                    conditions=[
+                        _email("foo@example.com"),
+                        Or(conditions=[_email("bar@example.com"), _email("foobar@example.com")]),
+                    ]
+                )
+            ],
+            [],
+        ),
+        (
+            "order_of_operations_with_groups_second_OR_first",
+            "(user.email:foo@example.com OR (user.email:bar@example.com OR user.email:foobar@example.com))",
+            [
+                Or(
+                    conditions=[
+                        _email("foo@example.com"),
+                        Or(conditions=[_email("bar@example.com"), _email("foobar@example.com")]),
+                    ]
+                )
+            ],
+            [],
+        ),
+        (
+            "order_of_operations_with_nested_groups",
+            "(user.email:foo@example.com OR (user.email:bar@example.com OR (user.email:foobar@example.com AND user.email:hello@example.com OR user.email:hi@example.com)))",
+            [
+                Or(
+                    conditions=[
+                        _email("foo@example.com"),
+                        Or(
+                            conditions=[
+                                _email("bar@example.com"),
+                                Or(
+                                    conditions=[
+                                        And(
+                                            conditions=[
+                                                _email("foobar@example.com"),
+                                                _email("hello@example.com"),
+                                            ]
+                                        ),
+                                        _email("hi@example.com"),
+                                    ]
+                                ),
+                            ]
+                        ),
+                    ]
+                )
+            ],
+            [],
+        ),
+        (
+            "message_outside_simple_grouped_OR",
+            "test (item1 OR item2)",
+            [
+                And(
+                    conditions=[
+                        _message("test"),
+                        Or(conditions=[_message("item1"), _message("item2")]),
+                    ]
+                )
+            ],
+            [],
+        ),
+        ("only_parens", "()", [_message("()")], []),
+        ("grouped_free_text", "(test)", [_message("test")], []),
+        (
+            "free_text_with_parens",
+            "undefined is not an object (evaluating 'function.name')",
+            [_message("undefined is not an object (evaluating 'function.name')")],
+            [],
+        ),
+        (
+            "free_text_AND_grouped_message",
+            "combined (free text) AND (grouped)",
+            [And(conditions=[_message("combined (free text)"), _message("grouped")])],
+            [],
+        ),
+        (
+            "free_text_OR_free_text",
+            "foo bar baz OR fizz buzz bizz",
+            [Or(conditions=[_message("foo bar baz"), _message("fizz buzz bizz")])],
+            [],
+        ),
+        (
+            "grouped_OR_and_OR",
+            "a:b (c:d OR e:f) g:h i:j OR k:l",
+            [
+                Or(
+                    conditions=[
+                        And(
+                            conditions=[
+                                _tag("a", "b"),
+                                And(
+                                    conditions=[
+                                        Or(conditions=[_tag("c", "d"), _tag("e", "f")]),
+                                        And(conditions=[_tag("g", "h"), _tag("i", "j")]),
+                                    ]
+                                ),
+                            ]
+                        ),
+                        _tag("k", "l"),
+                    ]
+                )
+            ],
+            [],
+        ),
+        (
+            "OR_and_grouped_OR",
+            "a:b OR c:d e:f g:h (i:j OR k:l)",
+            [
+                Or(
+                    conditions=[
+                        _tag("a", "b"),
+                        And(
+                            conditions=[
+                                _tag("c", "d"),
+                                And(
+                                    conditions=[
+                                        _tag("e", "f"),
+                                        And(
+                                            conditions=[
+                                                _tag("g", "h"),
+                                                Or(conditions=[_tag("i", "j"), _tag("k", "l")]),
+                                            ]
+                                        ),
+                                    ]
+                                ),
+                            ]
+                        ),
+                    ],
+                )
+            ],
+            [],
+        ),
+        (
+            "grouped_OR",
+            "(a:b OR c:d) e:f",
+            [And(conditions=[Or(conditions=[_tag("a", "b"), _tag("c", "d")]), _tag("e", "f")])],
+            [],
+        ),
+        (
+            "ORs_and_no_parens",
+            "a:b OR c:d e:f g:h i:j OR k:l",
+            [
+                Or(
+                    conditions=[
+                        _tag("a", "b"),
+                        Or(
+                            conditions=[
+                                And(
+                                    conditions=[
+                                        _tag("c", "d"),
+                                        And(
+                                            conditions=[
+                                                _tag("e", "f"),
+                                                And(conditions=[_tag("g", "h"), _tag("i", "j")]),
+                                            ]
+                                        ),
+                                    ]
+                                ),
+                                _tag("k", "l"),
+                            ],
+                        ),
+                    ]
+                )
+            ],
+            [],
+        ),
+        (
+            "grouped_OR_and_OR",
+            "(a:b OR c:d) e:f g:h OR i:j k:l",
+            [
+                Or(
+                    conditions=[
+                        And(
+                            conditions=[
+                                Or(conditions=[_tag("a", "b"), _tag("c", "d")]),
+                                And(conditions=[_tag("e", "f"), _tag("g", "h")]),
+                            ]
+                        ),
+                        And(conditions=[_tag("i", "j"), _tag("k", "l")]),
+                    ]
+                )
+            ],
+            [],
+        ),
+        (
+            "single_OR_and_no_parens",
+            "a:b c:d e:f OR g:h i:j",
+            [
+                Or(
+                    conditions=[
+                        And(
+                            conditions=[
+                                _tag("a", "b"),
+                                And(conditions=[_tag("c", "d"), _tag("e", "f")]),
+                            ]
+                        ),
+                        And(conditions=[_tag("g", "h"), _tag("i", "j")]),
+                    ]
+                ),
+            ],
+            [],
+        ),
+        (
+            "single_grouped_OR",
+            "a:b c:d (e:f OR g:h) i:j",
+            [
+                And(
+                    conditions=[
+                        _tag("a", "b"),
+                        And(
+                            conditions=[
+                                _tag("c", "d"),
+                                And(
+                                    conditions=[
+                                        Or(conditions=[_tag("e", "f"), _tag("g", "h")]),
+                                        _tag("i", "j"),
+                                    ]
+                                ),
+                            ]
+                        ),
+                    ]
+                )
+            ],
+            [],
+        ),
+        (
+            "negation_and_grouped_OR",
+            "!a:b c:d (e:f OR g:h) i:j",
+            [
+                And(
+                    conditions=[
+                        _ntag("a", "b"),
+                        And(
+                            conditions=[
+                                _tag("c", "d"),
+                                And(
+                                    conditions=[
+                                        Or(conditions=[_tag("e", "f"), _tag("g", "h")]),
+                                        _tag("i", "j"),
+                                    ]
+                                ),
+                            ]
+                        ),
+                    ]
+                )
+            ],
+            [],
+        ),
+        (
+            "nested_ORs_and_AND",
+            "(a:b OR (c:d AND (e:f OR (g:h AND e:f))))",
+            [
+                Or(
+                    conditions=[
+                        _tag("a", "b"),
+                        And(
+                            conditions=[
+                                _tag("c", "d"),
+                                Or(
+                                    conditions=[
+                                        _tag("e", "f"),
+                                        And(conditions=[_tag("g", "h"), _tag("e", "f")]),
+                                    ]
+                                ),
+                            ]
+                        ),
+                    ]
+                )
+            ],
+            [],
+        ),
+        (
+            "grouped_OR_then_AND_with_implied_AND",
+            "(a:b OR c:d) AND (e:f g:h)",
+            [
+                And(
+                    conditions=[
+                        Or(conditions=[_tag("a", "b"), _tag("c", "d")]),
+                        And(conditions=[_tag("e", "f"), _tag("g", "h")]),
+                    ]
+                )
+            ],
+            [],
+        ),
+        (
+            "aggregate_AND_with_2_counts",
+            "count():>1 AND count():<=3",
+            [],
+            [And(conditions=[_count(Op.GT, 1), _count(Op.LTE, 3)])],
+        ),
+        (
+            "aggregate_OR_with_2_counts",
+            "count():>1 OR count():<=3",
+            [],
+            [Or(conditions=[_count(Op.GT, 1), _count(Op.LTE, 3)])],
+        ),
+        (
+            "aggregate_order_of_operations_with_OR_then_AND",
+            "count():>1 OR count():>5 AND count():<=3",
+            [],
+            [
+                Or(
+                    conditions=[
+                        _count(Op.GT, 1),
+                        And(conditions=[_count(Op.GT, 5), _count(Op.LTE, 3)]),
+                    ]
+                )
+            ],
+        ),
+        (
+            "aggregate_order_of_operations_with_AND_then_OR",
+            "count():>1 AND count():<=3 OR count():>5",
+            [],
+            [
+                Or(
+                    conditions=[
+                        And(conditions=[_count(Op.GT, 1), _count(Op.LTE, 3)]),
+                        _count(Op.GT, 5),
+                    ]
+                )
+            ],
+        ),
+        (
+            "grouped_aggregate_OR_then_AND",
+            "(count():>1 OR count():>2) AND count():<=3",
+            [],
+            [
+                And(
+                    conditions=[
+                        Or(conditions=[_count(Op.GT, 1), _count(Op.GT, 2)]),
+                        _count(Op.LTE, 3),
+                    ]
+                )
+            ],
+        ),
+        (
+            "grouped_aggregate_AND_then_OR",
+            "(count():>1 AND count():>5) OR count():<=3",
+            [],
+            [
+                Or(
+                    conditions=[
+                        And(conditions=[_count(Op.GT, 1), _count(Op.GT, 5)]),
+                        _count(Op.LTE, 3),
+                    ]
+                )
+            ],
+        ),
+        ("aggregate_AND_tag", "count():>1 AND a:b", [_tag("a", "b")], [_count(Op.GT, 1)]),
+        (
+            "aggregate_AND_two_tags",
+            "count():>1 AND a:b c:d",
+            [And(conditions=[_tag("a", "b"), _tag("c", "d")])],
+            [_count(Op.GT, 1)],
+        ),
+        (
+            "ORed_tags_AND_aggregate",
+            "(a:b OR c:d) count():>1",
+            [Or(conditions=[_tag("a", "b"), _tag("c", "d")])],
+            [_count(Op.GT, 1)],
+        ),
+        (
+            "aggregate_like_message_and_columns",
+            "failure_rate():>0.003&& users:>10 event.type:transaction",
+            [
+                _message("failure_rate():>0.003&&"),
+                _tag("users", ">10"),
+                _cond("type", Op.EQ, "transaction"),
+            ],
+            [],
+        ),
+        (
+            "message_with_parens",
+            "TypeError Anonymous function(app/javascript/utils/transform-object-keys)",
+            [_message("TypeError Anonymous function(app/javascript/utils/transform-object-keys)")],
+            [],
+        ),
+        ("tag_containing_OR", "organization.slug:slug", [_tag("organization.slug", "slug")], []),
+        (
+            "in_search_then_AND",
+            'url:["a", "b"] AND release:test',
+            [And(conditions=[_tag("url", ["a", "b"]), _cond("release", Op.EQ, "test")])],
+            [],
+        ),
+        (
+            "in_search_then_OR",
+            'url:["a", "b"] OR release:test',
+            [Or(conditions=[_tag("url", ["a", "b"]), _cond("release", Op.EQ, "test")])],
+            [],
+        ),
+        (
+            "AND_multiple_in_searches",
+            'url:["a", "b"] AND url:["c", "d"] OR url:["e", "f"]',
+            [
+                Or(
+                    conditions=[
+                        And(conditions=[_tag("url", ["a", "b"]), _tag("url", ["c", "d"])]),
+                        _tag("url", ["e", "f"]),
+                    ]
+                )
+            ],
+            [],
+        ),
+    ],
+)
+def test_snql_boolean_search(description, query, expected_where, expected_having):
+    dataset = Dataset.Discover
+    params: ParamsType = {}
+    query_filter = QueryFilter(dataset, params)
+    where, having = query_filter.resolve_conditions(query, use_aggregate_conditions=True)
+    assert where == expected_where, description
+    assert having == expected_having, description
+
+
+@pytest.mark.parametrize(
+    "description,query,expected_message",
+    [
+        (
+            "missing_close_parens",
+            "(user.email:foo@example.com OR user.email:bar@example.com",
+            "Parse error at '(user.' (column 1). This is commonly caused by unmatched parentheses. Enclose any text in double quotes.",
+        ),
+        (
+            "missing_second_close_parens",
+            "((user.email:foo@example.com OR user.email:bar@example.com AND  user.email:bar@example.com)",
+            "Parse error at '((user' (column 1). This is commonly caused by unmatched parentheses. Enclose any text in double quotes.",
+        ),
+        (
+            "missing_open_parens",
+            "user.email:foo@example.com OR user.email:bar@example.com)",
+            "Parse error at '.com)' (column 57). This is commonly caused by unmatched parentheses. Enclose any text in double quotes.",
+        ),
+        (
+            "missing_second_open_parens",
+            "(user.email:foo@example.com OR user.email:bar@example.com AND  user.email:bar@example.com))",
+            "Parse error at 'com))' (column 91). This is commonly caused by unmatched parentheses. Enclose any text in double quotes.",
+        ),
+        (
+            "cannot_OR_aggregate_and_normal_filter",
+            "count():>1 OR a:b",
+            "Having an OR between aggregate filters and normal filters is invalid.",
+        ),
+        (
+            "cannot_OR_normal_filter_with_an_AND_of_aggregate_and_normal_filters",
+            "(count():>1 AND a:b) OR a:b",
+            "Having an OR between aggregate filters and normal filters is invalid.",
+        ),
+        (
+            "cannot_OR_an_AND_of_aggregate_and_normal_filters",
+            "(count():>1 AND a:b) OR (a:b AND count():>2)",
+            "Having an OR between aggregate filters and normal filters is invalid.",
+        ),
+        (
+            "cannot_nest_aggregate_filter_in_AND_condition_then_OR_with_normal_filter",
+            "a:b OR (c:d AND (e:f AND count():>1))",
+            "Having an OR between aggregate filters and normal filters is invalid.",
+        ),
+        (
+            "missing_left_hand_side_of_OR",
+            "OR a:b",
+            "Condition is missing on the left side of 'OR' operator",
+        ),
+        (
+            "missing_condition_between_OR_and_AND",
+            "a:b Or And c:d",
+            "Missing condition in between two condition operators: 'OR AND'",
+        ),
+        (
+            "missing_right_hand_side_of_AND",
+            "a:b AND c:d AND",
+            "Condition is missing on the right side of 'AND' operator",
+        ),
+        (
+            "missing_left_hand_side_of_OR_inside_parens",
+            "(OR a:b) AND c:d",
+            "Condition is missing on the left side of 'OR' operator",
+        ),
+    ],
+)
+def test_snql_malformed_boolean_search(description, query, expected_message):
+    dataset = Dataset.Discover
+    params: ParamsType = {}
+    query_filter = QueryFilter(dataset, params)
+    with pytest.raises(InvalidSearchQuery) as error:
+        where, having = query_filter.resolve_conditions(query, use_aggregate_conditions=True)
+    assert str(error.value) == expected_message, description
+
+
+class SnQLBooleanSearchQueryTest(TestCase):
+    def setUp(self):
+        self.project1 = self.create_project()
+        self.project2 = self.create_project()
+        self.project3 = self.create_project()
+
+        self.group1 = self.create_group(project=self.project1)
+        self.group2 = self.create_group(project=self.project1)
+        self.group3 = self.create_group(project=self.project1)
+
+        dataset = Dataset.Discover
+        params: ParamsType = {
+            "organization_id": self.organization.id,
+            "project_id": [self.project1.id, self.project2.id],
+        }
+        self.query_filter = QueryFilter(dataset, params)
+
+    def test_project_or(self):
+        query = f"project:{self.project1.slug} OR project:{self.project2.slug}"
+        where, having = self.query_filter.resolve_conditions(query, use_aggregate_conditions=True)
+        assert where == [Or(conditions=[_project(self.project1.id), _project(self.project2.id)])]
+        assert having == []
+
+    def test_project_and_with_parens(self):
+        query = f"(project:{self.project1.slug} OR project:{self.project2.slug}) AND a:b"
+        where, having = self.query_filter.resolve_conditions(query, use_aggregate_conditions=True)
+        assert where == [
+            And(
+                conditions=[
+                    Or(conditions=[_project(self.project1.id), _project(self.project2.id)]),
+                    _tag("a", "b"),
+                ]
+            )
+        ]
+        assert having == []
+
+    def test_project_or_with_nested_ands(self):
+        query = f"(project:{self.project1.slug} AND a:b) OR (project:{self.project1.slug} AND c:d)"
+        where, having = self.query_filter.resolve_conditions(query, use_aggregate_conditions=True)
+        assert where == [
+            Or(
+                conditions=[
+                    And(conditions=[_project(self.project1.id), _tag("a", "b")]),
+                    And(conditions=[_project(self.project1.id), _tag("c", "d")]),
+                ]
+            )
+        ]
+        assert having == []
+
+    def test_project_not_selected(self):
+        with self.assertRaisesRegexp(
+            InvalidSearchQuery,
+            re.escape(
+                f"Invalid query. Project(s) {str(self.project3.slug)} do not exist or are not actively selected."
+            ),
+        ):
+            query = f"project:{self.project1.slug} OR project:{self.project3.slug}"
+            self.query_filter.resolve_conditions(query, use_aggregate_conditions=True)
+
+    def test_issue_id_or(self):
+        query = f"issue.id:{self.group1.id} OR issue.id:{self.group2.id}"
+        where, having = self.query_filter.resolve_conditions(query, use_aggregate_conditions=True)
+        assert where == [
+            Or(
+                conditions=[
+                    _cond("group_id", Op.EQ, self.group1.id),
+                    _cond("group_id", Op.EQ, self.group2.id),
+                ]
+            )
+        ]
+        assert having == []
+
+    def test_issue_id_and(self):
+        query = f"issue.id:{self.group1.id} AND issue.id:{self.group1.id}"
+        where, having = self.query_filter.resolve_conditions(query, use_aggregate_conditions=True)
+        assert where == [
+            And(
+                conditions=[
+                    _cond("group_id", Op.EQ, self.group1.id),
+                    _cond("group_id", Op.EQ, self.group1.id),
+                ]
+            )
+        ]
+        assert having == []
+
+    def test_issue_id_or_with_parens(self):
+        query = f"(issue.id:{self.group1.id} AND issue.id:{self.group2.id}) OR issue.id:{self.group3.id}"
+        where, having = self.query_filter.resolve_conditions(query, use_aggregate_conditions=True)
+        assert where == [
+            Or(
+                conditions=[
+                    And(
+                        conditions=[
+                            _cond("group_id", Op.EQ, self.group1.id),
+                            _cond("group_id", Op.EQ, self.group2.id),
+                        ]
+                    ),
+                    _cond("group_id", Op.EQ, self.group3.id),
+                ]
+            )
+        ]
+        assert having == []
+
+    def test_issue_id_and_tag(self):
+        query = f"issue.id:{self.group1.id} AND a:b"
+        where, having = self.query_filter.resolve_conditions(query, use_aggregate_conditions=True)
+        assert where == [And(conditions=[_cond("group_id", Op.EQ, self.group1.id), _tag("a", "b")])]
+        assert having == []
+
+    def test_issue_id_or_tag(self):
+        query = f"issue.id:{self.group1.id} OR a:b"
+        where, having = self.query_filter.resolve_conditions(query, use_aggregate_conditions=True)
+        assert where == [Or(conditions=[_cond("group_id", Op.EQ, self.group1.id), _tag("a", "b")])]
+        assert having == []
+
+    def test_issue_id_or_with_parens_and_tag(self):
+        query = f"(issue.id:{self.group1.id} AND a:b) OR issue.id:{self.group2.id}"
+        where, having = self.query_filter.resolve_conditions(query, use_aggregate_conditions=True)
+        assert where == [
+            Or(
+                conditions=[
+                    And(conditions=[_cond("group_id", Op.EQ, self.group1.id), _tag("a", "b")]),
+                    _cond("group_id", Op.EQ, self.group2.id),
+                ]
+            )
+        ]
+        assert having == []
+
+    def test_issue_id_or_with_parens_and_multiple_tags(self):
+        query = f"(issue.id:{self.group1.id} AND a:b) OR c:d"
+        where, having = self.query_filter.resolve_conditions(query, use_aggregate_conditions=True)
+        assert where == [
+            Or(
+                conditions=[
+                    And(conditions=[_cond("group_id", Op.EQ, self.group1.id), _tag("a", "b")]),
+                    _tag("c", "d"),
+                ]
+            )
+        ]
+        assert having == []

+ 161 - 113
tests/sentry/snuba/test_discover.py

@@ -2594,17 +2594,23 @@ class QueryIntegrationTest(SnubaTestCase, TestCase):
             project_id=project3.id,
         )
 
-        result = discover.query(
-            selected_columns=["project", "message"],
-            query=f"project:{self.project.slug} OR project:{project2.slug}",
-            params={"project_id": [self.project.id, project2.id]},
-            orderby="message",
-        )
+        for use_snql in [False, True]:
+            result = discover.query(
+                selected_columns=["project", "message"],
+                query=f"project:{self.project.slug} OR project:{project2.slug}",
+                params={
+                    "project_id": [self.project.id, project2.id],
+                    "start": self.two_min_ago,
+                    "end": self.now,
+                },
+                orderby="message",
+                use_snql=use_snql,
+            )
 
-        data = result["data"]
-        assert len(data) == 2
-        assert data[0]["project"] == project2.slug
-        assert data[1]["project"] == self.project.slug
+            data = result["data"]
+            assert len(data) == 2, use_snql
+            assert data[0]["project"] == project2.slug, use_snql
+            assert data[1]["project"] == self.project.slug, use_snql
 
     def test_nested_conditional_filter(self):
         project2 = self.create_project(organization=self.organization)
@@ -2625,54 +2631,71 @@ class QueryIntegrationTest(SnubaTestCase, TestCase):
             project_id=project2.id,
         )
 
-        result = discover.query(
-            selected_columns=["release"],
-            query="(release:{} OR release:{}) AND project:{}".format(
-                "a" * 32, "b" * 32, self.project.slug
-            ),
-            params={"project_id": [self.project.id, project2.id]},
-            orderby="release",
-        )
+        for use_snql in [False, True]:
+            result = discover.query(
+                selected_columns=["release"],
+                query="(release:{} OR release:{}) AND project:{}".format(
+                    "a" * 32, "b" * 32, self.project.slug
+                ),
+                params={
+                    "project_id": [self.project.id, project2.id],
+                    "start": self.two_min_ago,
+                    "end": self.now,
+                },
+                orderby="release",
+                use_snql=use_snql,
+            )
 
-        data = result["data"]
-        assert len(data) == 2
-        assert data[0]["release"] == "a" * 32
-        assert data[1]["release"] == "b" * 32
+            data = result["data"]
+            assert len(data) == 2, use_snql
+            assert data[0]["release"] == "a" * 32, use_snql
+            assert data[1]["release"] == "b" * 32, use_snql
 
     def test_conditions_with_special_columns(self):
         for val in ["a", "b", "c"]:
             data = load_data("transaction")
-            data["timestamp"] = iso_format(before_now(seconds=1))
+            data["timestamp"] = iso_format(self.one_min_ago)
             data["transaction"] = val * 32
             data["message"] = val * 32
             data["tags"] = {"sub_customer.is-Enterprise-42": val * 32}
             self.store_event(data=data, project_id=self.project.id)
 
-        result = discover.query(
-            selected_columns=["title", "message"],
-            query="event.type:transaction (title:{} OR message:{})".format("a" * 32, "b" * 32),
-            params={"project_id": [self.project.id]},
-            orderby="title",
-        )
+        for use_snql in [False, True]:
+            result = discover.query(
+                selected_columns=["title", "message"],
+                query="event.type:transaction (title:{} OR message:{})".format("a" * 32, "b" * 32),
+                params={
+                    "project_id": [self.project.id],
+                    "start": self.two_min_ago,
+                    "end": self.now,
+                },
+                orderby="title",
+                use_snql=use_snql,
+            )
 
-        data = result["data"]
-        assert len(data) == 2
-        assert data[0]["title"] == "a" * 32
-        assert data[1]["title"] == "b" * 32
+            data = result["data"]
+            assert len(data) == 2, use_snql
+            assert data[0]["title"] == "a" * 32, use_snql
+            assert data[1]["title"] == "b" * 32, use_snql
 
-        result = discover.query(
-            selected_columns=["title", "sub_customer.is-Enterprise-42"],
-            query="event.type:transaction (title:{} AND sub_customer.is-Enterprise-42:{})".format(
-                "a" * 32, "a" * 32
-            ),
-            params={"project_id": [self.project.id]},
-            orderby="title",
-        )
+            result = discover.query(
+                selected_columns=["title", "sub_customer.is-Enterprise-42"],
+                query="event.type:transaction (title:{} AND sub_customer.is-Enterprise-42:{})".format(
+                    "a" * 32, "a" * 32
+                ),
+                params={
+                    "project_id": [self.project.id],
+                    "start": self.two_min_ago,
+                    "end": self.now,
+                },
+                orderby="title",
+                use_snql=use_snql,
+            )
 
-        data = result["data"]
-        assert len(data) == 1
-        assert data[0]["title"] == "a" * 32
-        assert data[0]["sub_customer.is-Enterprise-42"] == "a" * 32
+            data = result["data"]
+            assert len(data) == 1, use_snql
+            assert data[0]["title"] == "a" * 32, use_snql
+            assert data[0]["sub_customer.is-Enterprise-42"] == "a" * 32, use_snql
 
     def test_conditions_with_aggregates(self):
         events = [("a", 2), ("b", 3), ("c", 4)]
@@ -2680,26 +2703,32 @@ class QueryIntegrationTest(SnubaTestCase, TestCase):
             val = ev[0] * 32
             for i in range(ev[1]):
                 data = load_data("transaction")
-                data["timestamp"] = iso_format(before_now(seconds=1))
+                data["timestamp"] = iso_format(self.one_min_ago)
                 data["transaction"] = f"{val}-{i}"
                 data["message"] = val
                 data["tags"] = {"trek": val}
                 self.store_event(data=data, project_id=self.project.id)
 
-        result = discover.query(
-            selected_columns=["trek", "count()"],
-            query="event.type:transaction (trek:{} OR trek:{}) AND count():>2".format(
-                "a" * 32, "b" * 32
-            ),
-            params={"project_id": [self.project.id]},
-            orderby="trek",
-            use_aggregate_conditions=True,
-        )
+        for use_snql in [False, True]:
+            result = discover.query(
+                selected_columns=["trek", "count()"],
+                query="event.type:transaction (trek:{} OR trek:{}) AND count():>2".format(
+                    "a" * 32, "b" * 32
+                ),
+                params={
+                    "project_id": [self.project.id],
+                    "start": self.two_min_ago,
+                    "end": self.now,
+                },
+                orderby="trek",
+                use_aggregate_conditions=True,
+                use_snql=use_snql,
+            )
 
-        data = result["data"]
-        assert len(data) == 1
-        assert data[0]["trek"] == "b" * 32
-        assert data[0]["count"] == 3
+            data = result["data"]
+            assert len(data) == 1, use_snql
+            assert data[0]["trek"] == "b" * 32, use_snql
+            assert data[0]["count"] == 3, use_snql
 
     def test_conditions_with_nested_aggregates(self):
         events = [("a", 2), ("b", 3), ("c", 4)]
@@ -2707,38 +2736,50 @@ class QueryIntegrationTest(SnubaTestCase, TestCase):
             val = ev[0] * 32
             for i in range(ev[1]):
                 data = load_data("transaction")
-                data["timestamp"] = iso_format(before_now(seconds=1))
+                data["timestamp"] = iso_format(self.one_min_ago)
                 data["transaction"] = f"{val}-{i}"
                 data["message"] = val
                 data["tags"] = {"trek": val}
                 self.store_event(data=data, project_id=self.project.id)
 
-        result = discover.query(
-            selected_columns=["trek", "count()"],
-            query="(event.type:transaction AND (trek:{} AND (transaction:*{}* AND count():>2)))".format(
-                "b" * 32, "b" * 32
-            ),
-            params={"project_id": [self.project.id]},
-            orderby="trek",
-            use_aggregate_conditions=True,
-        )
-
-        data = result["data"]
-        assert len(data) == 1
-        assert data[0]["trek"] == "b" * 32
-        assert data[0]["count"] == 3
-
-        with pytest.raises(InvalidSearchQuery):
-            discover.query(
-                selected_columns=["trek", "transaction"],
+        for use_snql in [False, True]:
+            result = discover.query(
+                selected_columns=["trek", "count()"],
                 query="(event.type:transaction AND (trek:{} AND (transaction:*{}* AND count():>2)))".format(
                     "b" * 32, "b" * 32
                 ),
-                params={"project_id": [self.project.id]},
+                params={
+                    "project_id": [self.project.id],
+                    "start": self.two_min_ago,
+                    "end": self.now,
+                },
                 orderby="trek",
                 use_aggregate_conditions=True,
+                use_snql=use_snql,
             )
 
+            data = result["data"]
+            assert len(data) == 1, use_snql
+            assert data[0]["trek"] == "b" * 32, use_snql
+            assert data[0]["count"] == 3, use_snql
+
+            with pytest.raises(InvalidSearchQuery) as err:
+                discover.query(
+                    selected_columns=["trek", "transaction"],
+                    query="(event.type:transaction AND (trek:{} AND (transaction:*{}* AND count():>2)))".format(
+                        "b" * 32, "b" * 32
+                    ),
+                    params={
+                        "project_id": [self.project.id],
+                        "start": self.two_min_ago,
+                        "end": self.now,
+                    },
+                    orderby="trek",
+                    use_aggregate_conditions=True,
+                    use_snql=use_snql,
+                )
+            assert "used in a condition but is not a selected column" in str(err)
+
     def test_conditions_with_timestamps(self):
         events = [("a", 1), ("b", 2), ("c", 3)]
         for t, ev in enumerate(events):
@@ -2748,23 +2789,29 @@ class QueryIntegrationTest(SnubaTestCase, TestCase):
                 data["transaction"] = f"{val}"
                 self.store_event(data=data, project_id=self.project.id)
 
-        results = discover.query(
-            selected_columns=["transaction", "count()"],
-            query="event.type:transaction AND (timestamp:<{} OR timestamp:>{})".format(
-                iso_format(before_now(seconds=5)),
-                iso_format(before_now(seconds=3)),
-            ),
-            params={"project_id": [self.project.id]},
-            orderby="transaction",
-            use_aggregate_conditions=True,
-        )
+        for use_snql in [False, True]:
+            results = discover.query(
+                selected_columns=["transaction", "count()"],
+                query="event.type:transaction AND (timestamp:<{} OR timestamp:>{})".format(
+                    iso_format(before_now(seconds=5)),
+                    iso_format(before_now(seconds=3)),
+                ),
+                params={
+                    "project_id": [self.project.id],
+                    "start": self.two_min_ago,
+                    "end": self.now,
+                },
+                orderby="transaction",
+                use_aggregate_conditions=True,
+                use_snql=use_snql,
+            )
 
-        data = results["data"]
-        assert len(data) == 2
-        assert data[0]["transaction"] == "a" * 32
-        assert data[0]["count"] == 1
-        assert data[1]["transaction"] == "c" * 32
-        assert data[1]["count"] == 3
+            data = results["data"]
+            assert len(data) == 2, use_snql
+            assert data[0]["transaction"] == "a" * 32, use_snql
+            assert data[0]["count"] == 1, use_snql
+            assert data[1]["transaction"] == "c" * 32, use_snql
+            assert data[1]["count"] == 3, use_snql
 
     def test_timestamp_rollup_filter(self):
         event_hour = self.event_time.replace(minute=0, second=0)
@@ -4288,7 +4335,7 @@ class QueryTransformTest(TestCase):
             "data": [{"transaction": "api.do_things", "duration": 200}],
         }
         start_time = before_now(minutes=10)
-        end_time = before_now(seconds=1)
+        end_time = before_now(minutes=1)
         discover.query(
             selected_columns=["transaction", "transaction.duration"],
             query="http.method:GET",
@@ -4317,7 +4364,7 @@ class QueryTransformTest(TestCase):
             "data": [{"transaction": "api.do_things", "duration": 200}],
         }
         start_time = before_now(minutes=10)
-        end_time = before_now(seconds=1)
+        end_time = before_now(minutes=1)
         discover.query(
             selected_columns=["transaction", "avg(transaction.duration)"],
             query="http.method:GET avg(transaction.duration):>5",
@@ -4347,7 +4394,7 @@ class QueryTransformTest(TestCase):
             "data": [{"transaction": "api.do_things", "duration": 200}],
         }
         start_time = before_now(minutes=10)
-        end_time = before_now(seconds=1)
+        end_time = before_now(minutes=1)
         discover.query(
             selected_columns=["transaction", "p95()"],
             query="http.method:GET p95():>5",
@@ -4374,7 +4421,7 @@ class QueryTransformTest(TestCase):
     @patch("sentry.snuba.discover.raw_query")
     def test_duration_aliases(self, mock_query):
         start_time = before_now(minutes=10)
-        end_time = before_now(seconds=1)
+        end_time = before_now(minutes=1)
         test_cases = [
             ("1ms", 1),
             ("1.5s", 1500),
@@ -4420,7 +4467,7 @@ class QueryTransformTest(TestCase):
             "data": [{"transaction": "api.do_things", "duration": 200}],
         }
         start_time = before_now(minutes=10)
-        end_time = before_now(seconds=1)
+        end_time = before_now(minutes=1)
         discover.query(
             selected_columns=["transaction", "p95()"],
             query="http.method:GET p95():>5",
@@ -4451,7 +4498,7 @@ class QueryTransformTest(TestCase):
             "data": [{"transaction": "api.do_things", "duration": 200}],
         }
         start_time = before_now(minutes=10)
-        end_time = before_now(seconds=1)
+        end_time = before_now(minutes=1)
 
         discover.query(
             selected_columns=[
@@ -4487,7 +4534,7 @@ class QueryTransformTest(TestCase):
     @patch("sentry.snuba.discover.raw_query")
     def test_aggregate_duration_alias(self, mock_query):
         start_time = before_now(minutes=10)
-        end_time = before_now(seconds=1)
+        end_time = before_now(minutes=1)
 
         test_cases = [
             ("1ms", 1),
@@ -4538,7 +4585,7 @@ class QueryTransformTest(TestCase):
             "data": [{"transaction": "api.do_things", "duration": 200}],
         }
         start_time = before_now(minutes=10)
-        end_time = before_now(seconds=1)
+        end_time = before_now(minutes=1)
 
         with pytest.raises(InvalidSearchQuery):
             discover.query(
@@ -4555,7 +4602,7 @@ class QueryTransformTest(TestCase):
             "data": [{"transaction": "api.do_things", "duration": 200}],
         }
         start_time = before_now(minutes=10)
-        end_time = before_now(seconds=1)
+        end_time = before_now(minutes=1)
 
         with pytest.raises(InvalidSearchQuery):
             discover.query(
@@ -4573,7 +4620,7 @@ class QueryTransformTest(TestCase):
             "data": [{"transaction": "api.do_things", "duration": 200}],
         }
         start_time = before_now(minutes=10)
-        end_time = before_now(seconds=1)
+        end_time = before_now(minutes=1)
 
         with pytest.raises(AssertionError):
             discover.query(
@@ -4591,7 +4638,7 @@ class QueryTransformTest(TestCase):
             "data": [{"transaction": "api.do_things", "duration": 200}],
         }
         start_time = before_now(minutes=10)
-        end_time = before_now(seconds=1)
+        end_time = before_now(minutes=1)
 
         discover.query(
             selected_columns=["transaction", "p95()"],
@@ -4626,7 +4673,7 @@ class QueryTransformTest(TestCase):
             "data": [{"transaction": "api.do_things", "duration": 200}],
         }
         start_time = before_now(minutes=10)
-        end_time = before_now(seconds=1)
+        end_time = before_now(minutes=1)
 
         discover.query(
             selected_columns=["transaction", "min(timestamp)"],
@@ -5375,6 +5422,7 @@ class TimeseriesBase(SnubaTestCase, TestCase):
     def setUp(self):
         super().setUp()
 
+        self.one_min_ago = before_now(minutes=1)
         self.day_ago = before_now(days=1).replace(hour=10, minute=0, second=0, microsecond=0)
 
         self.store_event(
@@ -5631,11 +5679,11 @@ class TimeseriesQueryTest(TimeseriesBase):
         project3 = self.create_project(organization=self.organization)
 
         self.store_event(
-            data={"message": "hello", "timestamp": iso_format(before_now(minutes=1))},
+            data={"message": "hello", "timestamp": iso_format(self.one_min_ago)},
             project_id=project2.id,
         )
         self.store_event(
-            data={"message": "hello", "timestamp": iso_format(before_now(minutes=1))},
+            data={"message": "hello", "timestamp": iso_format(self.one_min_ago)},
             project_id=project3.id,
         )
 
@@ -5659,19 +5707,19 @@ class TimeseriesQueryTest(TimeseriesBase):
     def test_nested_conditional_filter(self):
         project2 = self.create_project(organization=self.organization)
         self.store_event(
-            data={"release": "a" * 32, "timestamp": iso_format(before_now(minutes=1))},
+            data={"release": "a" * 32, "timestamp": iso_format(self.one_min_ago)},
             project_id=self.project.id,
         )
         self.event = self.store_event(
-            data={"release": "b" * 32, "timestamp": iso_format(before_now(minutes=1))},
+            data={"release": "b" * 32, "timestamp": iso_format(self.one_min_ago)},
             project_id=self.project.id,
         )
         self.event = self.store_event(
-            data={"release": "c" * 32, "timestamp": iso_format(before_now(minutes=1))},
+            data={"release": "c" * 32, "timestamp": iso_format(self.one_min_ago)},
             project_id=self.project.id,
         )
         self.event = self.store_event(
-            data={"release": "a" * 32, "timestamp": iso_format(before_now(minutes=1))},
+            data={"release": "a" * 32, "timestamp": iso_format(self.one_min_ago)},
             project_id=project2.id,
         )