Skip to content
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
3 changes: 3 additions & 0 deletions packages/gooddata-sdk/src/gooddata_sdk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -254,9 +254,12 @@
AllTimeFilter,
AttributeFilter,
BoundedFilter,
CompoundMetricValueFilter,
Filter,
InlineFilter,
MetricValueComparisonCondition,
MetricValueFilter,
MetricValueRangeCondition,
NegativeAttributeFilter,
PositiveAttributeFilter,
RankingFilter,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,12 @@
AbsoluteDateFilter,
AllTimeFilter,
BoundedFilter,
CompoundMetricValueFilter,
Filter,
InlineFilter,
MetricValueComparisonCondition,
MetricValueFilter,
MetricValueRangeCondition,
NegativeAttributeFilter,
PositiveAttributeFilter,
RankingFilter,
Expand Down Expand Up @@ -116,6 +119,28 @@ def convert_filter(filter_dict: dict[str, Any]) -> Filter:
treat_nulls_as=f.get("treatNullValuesAs"),
)

if "compoundMeasureValueFilter" in filter_dict:
f = filter_dict["compoundMeasureValueFilter"]
Comment on lines +122 to +123
Copy link
Contributor

Choose a reason for hiding this comment

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

Simplification:

Suggested change
if "compoundMeasureValueFilter" in filter_dict:
f = filter_dict["compoundMeasureValueFilter"]
f = filter_dict.get("compoundMeasureValueFilter" , {})

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Not sure if I get this one, this would change the behaviour or not?

For non-compound filters, f becomes {} and the code would then hit f["measure"] (or similar) and fail with a KeyError, instead of cleanly falling through to the other filter types.
It also hides bugs: defaulting to {} makes “missing key” look like “present but empty”.


conditions: list[Union[MetricValueComparisonCondition, MetricValueRangeCondition]] = []
for condition in f.get("conditions", []):
if "comparison" in condition:
c = condition["comparison"]
conditions.append(MetricValueComparisonCondition(operator=c["operator"], value=c["value"]))
elif "range" in condition:
c = condition["range"]
conditions.append(
MetricValueRangeCondition(operator=c["operator"], from_value=c["from"], to_value=c["to"])
)
else:
raise ValueError(f"Unsupported measure value condition type: {condition}")

return CompoundMetricValueFilter(
metric=ref_extract(f["measure"]),
conditions=conditions,
treat_nulls_as=f.get("treatNullValuesAs"),
)

if "rankingFilter" in filter_dict:
f = filter_dict["rankingFilter"]

Expand Down
102 changes: 102 additions & 0 deletions packages/gooddata-sdk/src/gooddata_sdk/compute/model/filter.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@
from gooddata_api_client.models import (
ComparisonMeasureValueFilterComparisonMeasureValueFilter as ComparisonMeasureValueFilterBody,
)
from gooddata_api_client.models import (
CompoundMeasureValueFilterCompoundMeasureValueFilter as CompoundMeasureValueFilterBody,
)
from gooddata_api_client.models import NegativeAttributeFilterNegativeAttributeFilter as NegativeAttributeFilterBody
from gooddata_api_client.models import PositiveAttributeFilterPositiveAttributeFilter as PositiveAttributeFilterBody
from gooddata_api_client.models import RangeMeasureValueFilterRangeMeasureValueFilter as RangeMeasureValueFilterBody
Expand Down Expand Up @@ -483,6 +486,105 @@ def description(self, labels: dict[str, str], format_locale: Optional[str] = Non
)


@attrs.define(frozen=True, slots=True)
class MetricValueComparisonCondition:
operator: str
Copy link
Contributor

Choose a reason for hiding this comment

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

Consider using StrEnum or Literal. I suspect that the values will not change.

value: Union[int, float]

def as_api_model(self) -> afm_models.MeasureValueCondition:
comparison = afm_models.ComparisonConditionComparison(
operator=self.operator,
value=float(self.value),
_check_type=False,
)
return afm_models.MeasureValueCondition(comparison=comparison, _check_type=False)

def description(self) -> str:
return f"{_METRIC_VALUE_FILTER_OPERATOR_LABEL.get(self.operator, self.operator)} {float(self.value)}"


@attrs.define(frozen=True, slots=True)
class MetricValueRangeCondition:
operator: str
from_value: Union[int, float]
to_value: Union[int, float]

def as_api_model(self) -> afm_models.MeasureValueCondition:
range_body = afm_models.RangeConditionRange(
_from=float(self.from_value),
operator=self.operator,
to=float(self.to_value),
_check_type=False,
)
return afm_models.MeasureValueCondition(range=range_body, _check_type=False)

def description(self) -> str:
not_between = "not" if self.operator == "NOT_BETWEEN" else ""
Copy link
Contributor

@hkad98 hkad98 Feb 10, 2026

Choose a reason for hiding this comment

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

Based on usage in the example below, maybe "not " (missing space)?

return f"{not_between}between {float(self.from_value)} - {float(self.to_value)}"


MetricValueCondition = Union[MetricValueComparisonCondition, MetricValueRangeCondition]


class CompoundMetricValueFilter(Filter):
"""
Compound measure value filter.

Semantics match backend `CompoundMeasureValueFilter`: multiple conditions combined with OR logic.

Note:
- If `conditions` is empty, the filter is a noop (all rows are returned).
- `treat_nulls_as` is applied at the filter level (same for all conditions).
"""

def __init__(
self,
metric: Union[ObjId, str, Metric],
conditions: list[MetricValueCondition],
treat_nulls_as: Union[float, None] = None,
) -> None:
super().__init__()
self._metric = _extract_id_or_local_id(metric)
self._conditions = conditions
self._treat_nulls_as = treat_nulls_as

@property
def metric(self) -> Union[ObjId, str]:
return self._metric

@property
def conditions(self) -> list[MetricValueCondition]:
return self._conditions

@property
def treat_nulls_as(self) -> Union[float, None]:
return self._treat_nulls_as

def is_noop(self) -> bool:
return len(self.conditions) == 0

def as_api_model(self) -> afm_models.CompoundMeasureValueFilter:
measure = _to_identifier(self._metric)

kwargs: dict[str, Any] = dict(
measure=measure,
conditions=[c.as_api_model() for c in self.conditions],
_check_type=False,
)
if self.treat_nulls_as is not None:
kwargs["treat_null_values_as"] = self.treat_nulls_as

body = CompoundMeasureValueFilterBody(**kwargs)
return afm_models.CompoundMeasureValueFilter(body, _check_type=False)

def description(self, labels: dict[str, str], format_locale: Optional[str] = None) -> str:
metric_id = self.metric.id if isinstance(self.metric, ObjId) else self.metric
if not self.conditions:
return f"{labels.get(metric_id, metric_id)}: All"
conditions_str = " OR ".join([c.description() for c in self.conditions])
return f"{labels.get(metric_id, metric_id)}: {conditions_str}"


_RANKING_OPERATORS = {"TOP", "BOTTOM"}


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,12 @@
AbsoluteDateFilter,
ArithmeticMetric,
Attribute,
CompoundMetricValueFilter,
ComputeToSdkConverter,
InlineFilter,
MetricValueComparisonCondition,
MetricValueFilter,
MetricValueRangeCondition,
NegativeAttributeFilter,
PopDateMetric,
PopDatesetMetric,
Expand Down Expand Up @@ -180,6 +183,38 @@ def test_range_measure_value_filter_conversion():
assert result.treat_nulls_as == 42


def test_compound_measure_value_filter_conversion():
filter_dict = json.loads(
"""
{
"compoundMeasureValueFilter": {
"measure": { "localIdentifier": "measureLocalId" },
"conditions": [
{ "comparison": { "operator": "GREATER_THAN", "value": 100 } },
{ "range": { "operator": "BETWEEN", "from": 10, "to": 20 } }
],
"treatNullValuesAs": 0,
"applyOnResult": true
}
}
"""
)

result = ComputeToSdkConverter.convert_filter(filter_dict)

assert isinstance(result, CompoundMetricValueFilter)
assert result.metric == "measureLocalId"
assert result.treat_nulls_as == 0
assert len(result.conditions) == 2
assert isinstance(result.conditions[0], MetricValueComparisonCondition)
assert result.conditions[0].operator == "GREATER_THAN"
assert result.conditions[0].value == 100
assert isinstance(result.conditions[1], MetricValueRangeCondition)
assert result.conditions[1].operator == "BETWEEN"
assert result.conditions[1].from_value == 10
assert result.conditions[1].to_value == 20


def test_ranking_filter_conversion():
filter_dict = json.loads(
"""
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# (C) 2026 GoodData Corporation
from __future__ import annotations

from gooddata_sdk import (
CompoundMetricValueFilter,
MetricValueComparisonCondition,
MetricValueRangeCondition,
)
from gooddata_sdk.compute.model.base import ObjId


def test_compound_metric_value_filter_to_api_model():
f = CompoundMetricValueFilter(
metric="local_id1",
conditions=[
MetricValueComparisonCondition(operator="GREATER_THAN", value=10),
MetricValueRangeCondition(operator="BETWEEN", from_value=2, to_value=3),
],
treat_nulls_as=0,
)

assert f.is_noop() is False
assert f.as_api_model().to_dict() == {
"compound_measure_value_filter": {
"conditions": [
{"comparison": {"operator": "GREATER_THAN", "value": 10.0}},
{"range": {"_from": 2.0, "operator": "BETWEEN", "to": 3.0}},
],
"measure": {"local_identifier": "local_id1"},
"treat_null_values_as": 0,
}
}


def test_compound_metric_value_filter_noop_when_no_conditions():
f = CompoundMetricValueFilter(metric=ObjId(type="metric", id="metric.id"), conditions=[])
assert f.is_noop() is True
Loading