Skip to content

Commit 080f677

Browse files
authored
fix(validate): Check attributes in query even during error (#118018)
- Even when there's an error check the attributes - Parse errors will still cause an early exit and attributes to not be checked though
1 parent 6b71811 commit 080f677

4 files changed

Lines changed: 154 additions & 82 deletions

File tree

src/sentry/api/endpoints/organization_events_validate.py

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,11 @@ class AttributeValidation(NamedValidation):
4545
attrType: str | None
4646

4747

48+
@dataclass(kw_only=True)
49+
class QueryValidation(Validation):
50+
fields: list[AttributeValidation] = dataclass_field(default_factory=list)
51+
52+
4853
@dataclass(kw_only=True)
4954
class ValidationResponse:
5055
valid: bool
@@ -53,7 +58,7 @@ class ValidationResponse:
5358
field: list[AttributeValidation] = dataclass_field(default_factory=list)
5459
orderby: list[AttributeValidation] = dataclass_field(default_factory=list)
5560
projects: list[Validation] = dataclass_field(default_factory=list)
56-
query: list[Validation | AttributeValidation] = dataclass_field(default_factory=list)
61+
query: QueryValidation
5762

5863

5964
def serialize_type(search_type: constants.SearchType) -> str:
@@ -187,7 +192,7 @@ def get(self, request: Request, organization: Organization) -> Response:
187192
if not self.has_feature(organization, request):
188193
return Response(status=400)
189194

190-
response = ValidationResponse(valid=True)
195+
response = ValidationResponse(valid=True, query=QueryValidation(valid=True, error=None))
191196

192197
try:
193198
snuba_params = self.get_snuba_params(
@@ -239,23 +244,36 @@ def get(self, request: Request, organization: Organization) -> Response:
239244

240245
# Validate query
241246
query_string = request.GET.get("query", "")
242-
query_validity: list[AttributeValidation] = []
243247
query_attributes_to_lookup: dict[AttributeKey.Type.ValueType, list[ResolvedAttribute]] = {}
244248
query_columns = []
245249
try:
246-
# While resolve_query also runs parse_search_query, we don't need the resolved_query just want to dry-run it
247-
# to get any errors
248-
resolver.resolve_query(query_string)
249-
parsed_terms = resolver.parse_search_query(query_string)
250+
try:
251+
parsed_terms = resolver.parse_search_query(query_string)
252+
except InvalidSearchQuery as err:
253+
# If we fail to parse, try again but truncate the query to hopefully get some terms
254+
if err.extra is not None:
255+
try:
256+
parsed_terms = resolver.parse_search_query(
257+
query_string[: err.extra.get("idx", 0) - 1]
258+
)
259+
except InvalidSearchQuery:
260+
# If we fail again don't bubble the error up
261+
parsed_terms = []
262+
else:
263+
parsed_terms = []
250264
query_columns = resolver.collect_terms(parsed_terms)
251-
query_validity, query_attributes_to_lookup, valid = self.validate_columns(
265+
response.query.fields, query_attributes_to_lookup, valid = self.validate_columns(
252266
query_columns, resolver
253267
)
254268
if not valid:
255269
response.valid = valid
270+
# While resolve_query also runs parse_search_query, we don't need the resolved_query just want to dry-run it
271+
# to get any errors
272+
resolver.resolve_query(query_string)
256273
except InvalidSearchQuery as error:
257274
response.valid = False
258-
response.query.append(Validation(error=str(error), valid=False))
275+
response.query.error = str(error)
276+
response.query.valid = False
259277

260278
# Lookup unknown fields and add to validities
261279
# Combine the lookup dictionaries
@@ -297,12 +315,18 @@ def get(self, request: Request, organization: Organization) -> Response:
297315
column_validity.append(validity)
298316
if (
299317
resolved.public_alias in query_columns
300-
and validity not in query_validity
318+
and validity not in response.query.fields
301319
):
302-
query_validity.append(validity)
320+
response.query.fields.append(validity)
303321

304322
response.field.extend(column_validity)
305-
response.query.extend(query_validity)
323+
# If the response is still valid check if there's a field validity we wanna use
324+
if response.query.valid:
325+
for field in response.query.fields:
326+
if not field.valid:
327+
response.query.valid = False
328+
response.query.error = field.error
329+
break
306330

307331
# Validate orderby
308332
orderby_validity = []

src/sentry/api/event_search.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2018,12 +2018,14 @@ def parse_search_query(
20182018
idx = e.column()
20192019
prefix = query[max(0, idx - 5) : idx]
20202020
suffix = query[idx : (idx + 5)]
2021-
raise InvalidSearchQuery(
2021+
err = InvalidSearchQuery(
20222022
"{} {}".format(
20232023
f"Parse error at '{prefix}{suffix}' (column {e.column():d}).",
20242024
"This is commonly caused by unmatched parentheses. Enclose any text in double quotes.",
2025-
)
2025+
),
20262026
)
2027+
err.extra = {"idx": idx}
2028+
raise err
20272029

20282030
return SearchVisitor(
20292031
config,

src/sentry/exceptions.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from typing import Any
2+
13
from django.core.exceptions import SuspiciousOperation
24
from rest_framework.exceptions import ParseError
35

@@ -61,6 +63,7 @@ class ApiTokenLimitError(Exception):
6163

6264

6365
class InvalidSearchQuery(Exception):
66+
extra: dict[str, Any] | None = None
6467
pass
6568

6669

tests/sentry/api/endpoints/test_organization_events_validate.py

Lines changed: 111 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -345,33 +345,56 @@ def test_invalid_query(self) -> None:
345345
"project": [self.project.id],
346346
"dataset": "spans",
347347
"field": ["span.duration"],
348-
"query": "Thing AND",
348+
"query": "project:foo AND",
349349
}
350350
)
351351

352352
assert response.status_code == 400, response.content
353353
assert not response.data["valid"]
354-
assert response.data["query"] == [
355-
{"valid": False, "error": "Condition is missing on the right side of 'AND' operator"}
356-
]
354+
assert response.data["query"] == {
355+
"valid": False,
356+
"error": "Condition is missing on the right side of 'AND' operator",
357+
"fields": [
358+
{"attrType": "string", "error": None, "name": "project", "valid": True},
359+
],
360+
}
357361

358362
response = self.do_request(
359363
{
360364
"project": [self.project.id],
361365
"dataset": "spans",
362366
"field": ["span.duration"],
363-
"query": "span.duration:>hello",
367+
"query": "project:foo AND p90(hello",
364368
}
365369
)
366370

367371
assert response.status_code == 400, response.content
368372
assert not response.data["valid"]
369-
assert response.data["query"] == [
373+
assert response.data["query"] == {
374+
"valid": False,
375+
"error": "Parse error at ' p90(hello' (column 20). This is commonly caused by unmatched parentheses. Enclose any text in double quotes.",
376+
"fields": [
377+
{"attrType": "string", "error": None, "name": "project", "valid": True},
378+
{"attrType": "string", "error": None, "name": "message", "valid": True},
379+
],
380+
}
381+
382+
response = self.do_request(
370383
{
371-
"valid": False,
372-
"error": "span.duration: Invalid number: >hello. Expected number then optional k, m, or b suffix (e.g. 500k).",
384+
"project": [self.project.id],
385+
"dataset": "spans",
386+
"field": ["span.duration"],
387+
"query": "span.duration:>hello",
373388
}
374-
]
389+
)
390+
391+
assert response.status_code == 400, response.content
392+
assert not response.data["valid"]
393+
assert response.data["query"] == {
394+
"valid": False,
395+
"error": "span.duration: Invalid number: >hello. Expected number then optional k, m, or b suffix (e.g. 500k).",
396+
"fields": [],
397+
}
375398

376399
def test_valid_query(self) -> None:
377400
response = self.do_request(
@@ -385,20 +408,24 @@ def test_valid_query(self) -> None:
385408

386409
assert response.status_code == 200, response.content
387410
assert response.data["valid"]
388-
assert response.data["query"] == [
389-
{
390-
"error": None,
391-
"name": "span.duration",
392-
"valid": True,
393-
"attrType": "number",
394-
},
395-
{
396-
"error": None,
397-
"name": "p95(span.duration)",
398-
"valid": True,
399-
"attrType": "number",
400-
},
401-
]
411+
assert response.data["query"] == {
412+
"valid": True,
413+
"error": None,
414+
"fields": [
415+
{
416+
"error": None,
417+
"name": "span.duration",
418+
"valid": True,
419+
"attrType": "number",
420+
},
421+
{
422+
"error": None,
423+
"name": "p95(span.duration)",
424+
"valid": True,
425+
"attrType": "number",
426+
},
427+
],
428+
}
402429

403430
def test_mixed_validity_query(self) -> None:
404431
response = self.do_request(
@@ -412,32 +439,36 @@ def test_mixed_validity_query(self) -> None:
412439

413440
assert response.status_code == 400, response.content
414441
assert not response.data["valid"]
415-
assert response.data["query"] == [
416-
{
417-
"error": None,
418-
"name": "span.duration",
419-
"valid": True,
420-
"attrType": "number",
421-
},
422-
{
423-
"error": "Unknown attribute",
424-
"name": "hello",
425-
"valid": False,
426-
"attrType": None,
427-
},
428-
{
429-
"error": "Unknown attribute",
430-
"name": "world",
431-
"valid": False,
432-
"attrType": None,
433-
},
434-
{
435-
"error": "Unknown attribute",
436-
"name": "or",
437-
"valid": False,
438-
"attrType": None,
439-
},
440-
]
442+
assert response.data["query"] == {
443+
"error": "Unknown attribute",
444+
"valid": False,
445+
"fields": [
446+
{
447+
"error": None,
448+
"name": "span.duration",
449+
"valid": True,
450+
"attrType": "number",
451+
},
452+
{
453+
"error": "Unknown attribute",
454+
"name": "hello",
455+
"valid": False,
456+
"attrType": None,
457+
},
458+
{
459+
"error": "Unknown attribute",
460+
"name": "world",
461+
"valid": False,
462+
"attrType": None,
463+
},
464+
{
465+
"error": "Unknown attribute",
466+
"name": "or",
467+
"valid": False,
468+
"attrType": None,
469+
},
470+
],
471+
}
441472

442473
def test_user_tags_in_storage_for_query(self) -> None:
443474
self.store_spans(
@@ -460,9 +491,13 @@ def test_user_tags_in_storage_for_query(self) -> None:
460491

461492
assert response.status_code == 200, response.content
462493
assert response.data["valid"]
463-
assert response.data["query"] == [
464-
{"error": None, "name": "my.custom.tag", "valid": True, "attrType": "string"},
465-
]
494+
assert response.data["query"] == {
495+
"error": None,
496+
"valid": True,
497+
"fields": [
498+
{"error": None, "name": "my.custom.tag", "valid": True, "attrType": "string"},
499+
],
500+
}
466501

467502
def test_invalid_field_in_fields_and_query(self) -> None:
468503
response = self.do_request(
@@ -484,14 +519,18 @@ def test_invalid_field_in_fields_and_query(self) -> None:
484519
"attrType": None,
485520
},
486521
]
487-
assert response.data["query"] == [
488-
{
489-
"error": "Unknown attribute",
490-
"name": "hello",
491-
"valid": False,
492-
"attrType": None,
493-
},
494-
]
522+
assert response.data["query"] == {
523+
"error": "Unknown attribute",
524+
"valid": False,
525+
"fields": [
526+
{
527+
"error": "Unknown attribute",
528+
"name": "hello",
529+
"valid": False,
530+
"attrType": None,
531+
},
532+
],
533+
}
495534

496535
def test_multiple_invalid_issues(self) -> None:
497536
response = self.do_request(
@@ -514,14 +553,18 @@ def test_multiple_invalid_issues(self) -> None:
514553
"attrType": None,
515554
},
516555
]
517-
assert response.data["query"] == [
518-
{
519-
"error": "Unknown attribute",
520-
"name": "hello",
521-
"valid": False,
522-
"attrType": None,
523-
},
524-
]
556+
assert response.data["query"] == {
557+
"error": "Unknown attribute",
558+
"valid": False,
559+
"fields": [
560+
{
561+
"error": "Unknown attribute",
562+
"name": "hello",
563+
"valid": False,
564+
"attrType": None,
565+
},
566+
],
567+
}
525568
assert response.data["orderby"] == [
526569
{
527570
"error": "Orderby must also be a selected field",

0 commit comments

Comments
 (0)