Skip to content
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

feat(aci): schema validation for DataCondition.comparison #83457

Merged
merged 5 commits into from
Jan 15, 2025
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Expand Up @@ -11,13 +11,29 @@

@condition_handler_registry.register(Condition.AGE_COMPARISON)
class AgeComparisonConditionHandler(DataConditionHandler[WorkflowJob]):
@staticmethod
def comparison_json_schema() -> dict[str, Any]:
return {
"type": "object",
"properties": {
"comparison_type": {
"type": "string",
"enum": [AgeComparisonType.OLDER, AgeComparisonType.NEWER],
},
"value": {"type": "integer", "minimum": 0},
"time": {"type": "string", "enum": list(timeranges.keys())},
},
"required": ["comparison_type", "value", "time"],
"additionalProperties": False,
}

@staticmethod
def evaluate_value(job: WorkflowJob, comparison: Any) -> bool:
event = job["event"]
first_seen = event.group.first_seen
current_time = timezone.now()
comparison_type = comparison.get("comparison_type")
time = comparison.get("time")
comparison_type = comparison["comparison_type"]
time = comparison["time"]

if (
not comparison_type
Expand All @@ -31,7 +47,7 @@ def evaluate_value(job: WorkflowJob, comparison: Any) -> bool:
return False

try:
value = int(comparison.get("value"))
value = int(comparison["value"])
except (TypeError, ValueError):
return False

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -165,10 +165,9 @@ def create_tagged_event_data_condition(
def create_age_comparison_data_condition(
data: dict[str, Any], dcg: DataConditionGroup
) -> DataCondition:
# TODO: Add comparison validation (error if not enough information)
comparison = {
"comparison_type": data["comparison_type"],
"value": data["value"],
"value": int(data["value"]),
"time": data["time"],
}

Expand Down
14 changes: 14 additions & 0 deletions src/sentry/workflow_engine/models/data_condition.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
from typing import Any, TypeVar, cast

from django.db import models
from django.db.models.signals import pre_save
from django.dispatch import receiver

from sentry.backup.scopes import RelocationScope
from sentry.db.models import DefaultFieldsModel, region_silo_model, sane_repr
Expand Down Expand Up @@ -121,3 +123,15 @@ def evaluate_value(self, value: T) -> DataConditionResult:

result = handler.evaluate_value(value, self.comparison)
return self.get_condition_result() if result else None


@receiver(pre_save, sender=DataCondition)
def enforce_comparison_schema(sender, instance: DataCondition, **kwargs):
from jsonschema import ValidationError, validate
cathteng marked this conversation as resolved.
Show resolved Hide resolved

schema = condition_handler_registry.get(instance.type).comparison_json_schema()

try:
validate(instance.comparison, schema)
except ValidationError as e:
raise ValidationError(f"Invalid config: {e.message}")
5 changes: 5 additions & 0 deletions src/sentry/workflow_engine/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ def bulk_get_query_object(data_sources) -> dict[int, T | None]:


class DataConditionHandler(Generic[T]):
@staticmethod
def comparison_json_schema() -> dict[str, Any]:
Copy link
Member Author

Choose a reason for hiding this comment

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

not sure how to have an abstract static property 🤔

Copy link
Contributor

Choose a reason for hiding this comment

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

Copy link
Member Author

@cathteng cathteng Jan 14, 2025

Choose a reason for hiding this comment

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

i think i assumed we don't want to initialize the handler, but we can! added abstract property

# TODO(cathy): raise NotImplementedError
return {}

@staticmethod
def evaluate_value(value: T, comparison: Any) -> DataConditionResult:
raise NotImplementedError
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
from datetime import datetime, timedelta, timezone

import pytest
from jsonschema import ValidationError

from sentry.rules.age import AgeComparisonType
from sentry.rules.filters.age_comparison import AgeComparisonFilter
from sentry.testutils.helpers.datetime import freeze_time
Expand Down Expand Up @@ -36,7 +39,7 @@ def setUp(self):
)
self.dc = self.create_data_condition(
type=self.condition,
comparison={"comparison_type": AgeComparisonType.OLDER, "value": "10", "time": "hour"},
comparison={"comparison_type": AgeComparisonType.OLDER, "value": 10, "time": "hour"},
condition_result=True,
)

Expand All @@ -47,16 +50,30 @@ def test_dual_write(self):
assert dc.type == self.condition
assert dc.comparison == {
"comparison_type": AgeComparisonType.OLDER,
"value": "10",
"value": 10,
"time": "hour",
}
assert dc.condition_result is True
assert dc.condition_group == dcg

def test_json_schema(self):
with pytest.raises(ValidationError):
self.dc.comparison.update({"time": "asdf"})
Copy link
Member

Choose a reason for hiding this comment

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

the validation error is raised on save, right? maybe you can put the update outside of the pytest raise here to make that more clear

self.dc.save()

with pytest.raises(ValidationError):
self.dc.comparison.update({"value": "bad_value"})
self.dc.save()

with pytest.raises(ValidationError):
self.dc.comparison.update({"comparison_type": "bad_value"})
self.dc.save()

def test_older_applies_correctly(self):
self.dc.update(
comparison={"comparison_type": AgeComparisonType.OLDER, "value": "10", "time": "hour"}
self.dc.comparison.update(
{"comparison_type": AgeComparisonType.OLDER, "value": 10, "time": "hour"}
)
self.dc.save()

self.group.update(first_seen=datetime.now(timezone.utc) - timedelta(hours=3))
self.assert_does_not_pass(self.dc, self.job)
Expand All @@ -67,22 +84,13 @@ def test_older_applies_correctly(self):
self.assert_passes(self.dc, self.job)

def test_newer_applies_correctly(self):
self.dc.update(
comparison={"comparison_type": AgeComparisonType.NEWER, "value": "10", "time": "hour"}
self.dc.comparison.update(
{"comparison_type": AgeComparisonType.NEWER, "value": 10, "time": "hour"}
)
self.dc.save()

self.group.update(first_seen=datetime.now(timezone.utc) - timedelta(hours=3))
self.assert_passes(self.dc, self.job)

self.group.update(first_seen=datetime.now(timezone.utc) - timedelta(hours=10))
self.assert_does_not_pass(self.dc, self.job)

def test_fails_on_insufficient_data(self):
self.dc.update(comparison={"time": "hour"})
self.assert_does_not_pass(self.dc, self.job)

self.dc.update(comparison={"value": "bad_value"})
self.assert_does_not_pass(self.dc, self.job)

self.dc.update(comparison={"comparison_type": "bad_value"})
self.assert_does_not_pass(self.dc, self.job)
Loading