Skip to content

feat(aci): Use search parser for Detector and Workflow index queries #94645

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Jul 1, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
from django.db.models import Count
from functools import partial

from django.db.models import Count, Q
from django.db.models.query import QuerySet
from drf_spectacular.utils import PolymorphicProxySerializer, extend_schema
from rest_framework import status
from rest_framework.exceptions import ValidationError
Expand All @@ -10,6 +13,8 @@
from sentry.api.api_publish_status import ApiPublishStatus
from sentry.api.base import region_silo_endpoint
from sentry.api.bases import OrganizationAlertRulePermission, OrganizationEndpoint
from sentry.api.event_search import SearchConfig, SearchFilter, SearchKey, default_config
from sentry.api.event_search import parse_search_query as base_parse_search_query
from sentry.api.exceptions import ResourceDoesNotExist
from sentry.api.paginator import OffsetPaginator
from sentry.api.serializers import serialize
Expand All @@ -20,18 +25,26 @@
RESPONSE_UNAUTHORIZED,
)
from sentry.apidocs.parameters import DetectorParams, GlobalParams, OrganizationParams
from sentry.db.models.query import in_icontains, in_iexact
from sentry.incidents.grouptype import MetricIssue
from sentry.issues import grouptype
from sentry.models.organization import Organization
from sentry.models.project import Project
from sentry.search.utils import tokenize_query
from sentry.workflow_engine.endpoints.serializers import DetectorSerializer
from sentry.workflow_engine.endpoints.utils.filters import apply_filter
from sentry.workflow_engine.endpoints.utils.sortby import SortByParam
from sentry.workflow_engine.endpoints.validators.base import BaseDetectorTypeValidator
from sentry.workflow_engine.endpoints.validators.utils import get_unknown_detector_type_error
from sentry.workflow_engine.models import Detector

detector_search_config = SearchConfig.create_from(
default_config,
text_operator_keys={"name", "type"},
allowed_keys={"name", "type"},
allow_boolean=False,
free_text_key="query",
)
parse_detector_query = partial(base_parse_search_query, config=detector_search_config)

# Maps API field name to database field name, with synthetic aggregate fields keeping
# to our field naming scheme for consistency.
SORT_ATTRS = {
Expand Down Expand Up @@ -102,7 +115,7 @@ def get(self, request: Request, organization: Organization) -> Response:
Return a list of detectors for a given organization.
"""
projects = self.get_projects(request, organization)
queryset = Detector.objects.filter(
queryset: QuerySet[Detector] = Detector.objects.filter(
project_id__in=projects,
)

Expand All @@ -114,18 +127,20 @@ def get(self, request: Request, organization: Organization) -> Response:
queryset = queryset.filter(id__in=ids)

if raw_query := request.GET.get("query"):
tokenized_query = tokenize_query(raw_query)
for key, values in tokenized_query.items():
match key:
case "name":
queryset = queryset.filter(in_iexact("name", values))
case "type":
queryset = queryset.filter(in_iexact("type", values))
case "query":
for filter in parse_detector_query(raw_query):
assert isinstance(filter, SearchFilter)
match filter:
case SearchFilter(key=SearchKey("name"), operator=("=" | "IN" | "!=")):
queryset = apply_filter(queryset, filter, "name")
case SearchFilter(key=SearchKey("type"), operator=("=" | "IN" | "!=")):
queryset = apply_filter(queryset, filter, "type")
case SearchFilter(key=SearchKey("query"), operator="="):
# 'query' is our free text key; all free text gets returned here
# as '=', and we search any relevant fields for it.
queryset = queryset.filter(
in_icontains("description", values)
| in_icontains("name", values)
| in_icontains("type", values)
Q(description__icontains=filter.value.value)
| Q(name__icontains=filter.value.value)
| Q(type__icontains=filter.value.value)
).distinct()

sort_by = SortByParam.parse(request.GET.get("sortBy", "id"), SORT_ATTRS)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from datetime import datetime
from functools import partial

from django.db.models import Count, Max, Q
from django.db.models import Count, Max, Q, QuerySet
from django.db.models.functions import Coalesce
from drf_spectacular.utils import extend_schema
from rest_framework import status
Expand All @@ -12,6 +13,8 @@
from sentry.api.api_publish_status import ApiPublishStatus
from sentry.api.base import region_silo_endpoint
from sentry.api.bases import OrganizationEndpoint
from sentry.api.event_search import SearchConfig, SearchFilter, SearchKey, default_config
from sentry.api.event_search import parse_search_query as base_parse_search_query
from sentry.api.exceptions import ResourceDoesNotExist
from sentry.api.paginator import OffsetPaginator
from sentry.api.serializers import serialize
Expand All @@ -22,10 +25,9 @@
RESPONSE_UNAUTHORIZED,
)
from sentry.apidocs.parameters import GlobalParams, OrganizationParams, WorkflowParams
from sentry.db.models.query import in_icontains, in_iexact
from sentry.search.utils import tokenize_query
from sentry.utils.dates import ensure_aware
from sentry.workflow_engine.endpoints.serializers import WorkflowSerializer
from sentry.workflow_engine.endpoints.utils.filters import apply_filter
from sentry.workflow_engine.endpoints.utils.sortby import SortByParam
from sentry.workflow_engine.endpoints.validators.base.workflow import WorkflowValidator
from sentry.workflow_engine.models import Workflow
Expand All @@ -42,6 +44,15 @@
"lastTriggered": "last_triggered",
}

workflow_search_config = SearchConfig.create_from(
default_config,
text_operator_keys={"name", "action"},
allowed_keys={"name", "action"},
allow_boolean=False,
free_text_key="query",
)
parse_workflow_query = partial(base_parse_search_query, config=workflow_search_config)


class OrganizationWorkflowEndpoint(OrganizationEndpoint):
def convert_args(self, request: Request, workflow_id, *args, **kwargs):
Expand Down Expand Up @@ -87,7 +98,7 @@ def get(self, request, organization):
"""
sort_by = SortByParam.parse(request.GET.get("sortBy", "id"), SORT_COL_MAP)

queryset = Workflow.objects.filter(organization_id=organization.id)
queryset: QuerySet[Workflow] = Workflow.objects.filter(organization_id=organization.id)

if raw_idlist := request.GET.getlist("id"):
try:
Expand All @@ -97,24 +108,25 @@ def get(self, request, organization):
queryset = queryset.filter(id__in=ids)

if raw_query := request.GET.get("query"):
tokenized_query = tokenize_query(raw_query)
for key, values in tokenized_query.items():
match key:
case "name":
queryset = queryset.filter(in_iexact("name", values))
case "action":
queryset = queryset.filter(
in_iexact(
"workflowdataconditiongroup__condition_group__dataconditiongroupaction__action__type",
values,
)
).distinct()
case "query":
for filter in parse_workflow_query(raw_query):
assert isinstance(filter, SearchFilter)
match filter:
case SearchFilter(key=SearchKey("name"), operator=("=" | "IN" | "!=")):
queryset = apply_filter(queryset, filter, "name")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you know if this works for wildcards like name:*issue*? The parser does look for those, just not sure if what it outputs will work with the __iexact filtering in apply_filter or not

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wasn't targeting full syntax support, just more support with a parser that makes it easier for us to extend support as needed. That said, it's not too hard, so I added wildcard.

case SearchFilter(key=SearchKey("action"), operator=("=" | "IN" | "!=")):
queryset = apply_filter(
queryset,
filter,
"workflowdataconditiongroup__condition_group__dataconditiongroupaction__action__type",
distinct=True,
)
case SearchFilter(key=SearchKey("query"), operator="="):
# 'query' is our free text key; all free text gets returned here
# as '=', and we search any relevant fields for it.
queryset = queryset.filter(
in_icontains("name", values)
| in_icontains(
"workflowdataconditiongroup__condition_group__dataconditiongroupaction__action__type",
values,
Q(name__icontains=filter.value.value)
| Q(
workflowdataconditiongroup__condition_group__dataconditiongroupaction__action__type__icontains=filter.value.value,
)
).distinct()
case _:
Expand Down
34 changes: 34 additions & 0 deletions src/sentry/workflow_engine/endpoints/utils/filters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
from django.db.models import Model, Q, QuerySet

from sentry.api.event_search import SearchFilter
from sentry.db.models.query import in_iexact


def apply_filter[
T: Model
](queryset: QuerySet[T], filter: SearchFilter, column: str, distinct: bool = False) -> QuerySet[T]:
"""
Apply a search filter to a Django queryset with case-insensitive matching.

Supports operators: "=" (exact), "!=" (exclude), "IN" (containment).
"""
match filter.operator:
case "!=":
qs = queryset.exclude(**{f"{column}__iexact": filter.value.value})
case "IN":
qs = queryset.filter(in_iexact(column, filter.value.value))
case "=":
kind, value_o = filter.value.classify_and_format_wildcard()
if kind == "infix":
qs = queryset.filter(Q(**{f"{column}__icontains": value_o}))
elif kind == "suffix":
qs = queryset.filter(Q(**{f"{column}__iendswith": value_o}))
elif kind == "prefix":
qs = queryset.filter(Q(**{f"{column}__istartswith": value_o}))
else:
qs = queryset.filter(**{f"{column}__iexact": filter.value.value})
case _:
raise ValueError(f"Invalid operator: {filter.operator}")
if distinct:
return qs.distinct()
return qs
Original file line number Diff line number Diff line change
Expand Up @@ -236,10 +236,16 @@ def test_query_by_type(self):
# Query for multiple types.
response2 = self.get_success_response(
self.organization.slug,
qs_params={"project": self.project.id, "query": "type:error type:metric_issue"},
qs_params={"project": self.project.id, "query": "type:[error, metric_issue]"},
)
assert {d["name"] for d in response2.data} == {detector.name, detector2.name}

response3 = self.get_success_response(
self.organization.slug,
qs_params={"project": self.project.id, "query": "!type:metric_issue"},
)
assert {d["name"] for d in response3.data} == {detector2.name}

def test_general_query(self):
detector = self.create_detector(
project_id=self.project.id,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,14 @@ def test_query_filter_by_name(self):
== self.workflow.name
)

# wildcard
response2 = self.get_success_response(
self.organization.slug, qs_params={"query": "name:ap*"}
)
assert len(response2.data) == 1
assert response2.data[0]["name"] == self.workflow.name

# Non-match
response3 = self.get_success_response(
self.organization.slug, qs_params={"query": "Chicago"}
)
Expand Down Expand Up @@ -276,7 +284,9 @@ def test_filter_by_project(self):
def test_query_filter_by_action(self):
self._create_action_for_workflow(self.workflow, Action.Type.SLACK, self.FAKE_SLACK_CONFIG)
self._create_action_for_workflow(self.workflow, Action.Type.SLACK, self.FAKE_SLACK_CONFIG)
self._create_action_for_workflow(self.workflow, Action.Type.EMAIL, self.FAKE_EMAIL_CONFIG)
self._create_action_for_workflow(
self.workflow_two, Action.Type.EMAIL, self.FAKE_EMAIL_CONFIG
)

# Two actions should match, but they are from the same workflow so we only expect
# one result.
Expand Down Expand Up @@ -306,6 +316,12 @@ def test_query_filter_by_action(self):
)
assert len(response2.data) == 0

response3 = self.get_success_response(
self.organization.slug, qs_params={"query": "action:[slack,email]"}
)
assert len(response3.data) == 2
assert {self.workflow.name, self.workflow_two.name} == {w["name"] for w in response3.data}

def test_compound_query(self):
self.create_detector_workflow(
workflow=self.workflow, detector=self.create_detector(project=self.project)
Expand Down
Loading