From 642305a0816f941c4769caaf4462a5259b853599 Mon Sep 17 00:00:00 2001 From: Petr Jeske Date: Tue, 10 Feb 2026 15:55:43 +0100 Subject: [PATCH] feat: Add CompoundMeasureValueFilter Adding CompoundMeasureValueFilter definition, which can be used to define several numerical conditions in a single filter. JIRA: LX-2073 risk: low --- .../gooddata-sdk/src/gooddata_sdk/__init__.py | 3 + .../compute/compute_to_sdk_converter.py | 25 +++++ .../src/gooddata_sdk/compute/model/filter.py | 102 ++++++++++++++++++ .../compute/test_compute_to_sdk_converter.py | 35 ++++++ .../test_compound_metric_value_filter.py | 37 +++++++ 5 files changed, 202 insertions(+) create mode 100644 packages/gooddata-sdk/tests/compute_model/test_compound_metric_value_filter.py diff --git a/packages/gooddata-sdk/src/gooddata_sdk/__init__.py b/packages/gooddata-sdk/src/gooddata_sdk/__init__.py index 3e3e33782..5f9fd1201 100644 --- a/packages/gooddata-sdk/src/gooddata_sdk/__init__.py +++ b/packages/gooddata-sdk/src/gooddata_sdk/__init__.py @@ -254,9 +254,12 @@ AllTimeFilter, AttributeFilter, BoundedFilter, + CompoundMetricValueFilter, Filter, InlineFilter, + MetricValueComparisonCondition, MetricValueFilter, + MetricValueRangeCondition, NegativeAttributeFilter, PositiveAttributeFilter, RankingFilter, diff --git a/packages/gooddata-sdk/src/gooddata_sdk/compute/compute_to_sdk_converter.py b/packages/gooddata-sdk/src/gooddata_sdk/compute/compute_to_sdk_converter.py index ff19f1f23..16d6fba8e 100644 --- a/packages/gooddata-sdk/src/gooddata_sdk/compute/compute_to_sdk_converter.py +++ b/packages/gooddata-sdk/src/gooddata_sdk/compute/compute_to_sdk_converter.py @@ -7,9 +7,12 @@ AbsoluteDateFilter, AllTimeFilter, BoundedFilter, + CompoundMetricValueFilter, Filter, InlineFilter, + MetricValueComparisonCondition, MetricValueFilter, + MetricValueRangeCondition, NegativeAttributeFilter, PositiveAttributeFilter, RankingFilter, @@ -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"] + + 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"] diff --git a/packages/gooddata-sdk/src/gooddata_sdk/compute/model/filter.py b/packages/gooddata-sdk/src/gooddata_sdk/compute/model/filter.py index 4d67730cd..74ea5aa44 100644 --- a/packages/gooddata-sdk/src/gooddata_sdk/compute/model/filter.py +++ b/packages/gooddata-sdk/src/gooddata_sdk/compute/model/filter.py @@ -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 @@ -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 + 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 "" + 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"} diff --git a/packages/gooddata-sdk/tests/compute/test_compute_to_sdk_converter.py b/packages/gooddata-sdk/tests/compute/test_compute_to_sdk_converter.py index 4502b246c..2ee2d5b2e 100644 --- a/packages/gooddata-sdk/tests/compute/test_compute_to_sdk_converter.py +++ b/packages/gooddata-sdk/tests/compute/test_compute_to_sdk_converter.py @@ -5,9 +5,12 @@ AbsoluteDateFilter, ArithmeticMetric, Attribute, + CompoundMetricValueFilter, ComputeToSdkConverter, InlineFilter, + MetricValueComparisonCondition, MetricValueFilter, + MetricValueRangeCondition, NegativeAttributeFilter, PopDateMetric, PopDatesetMetric, @@ -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( """ diff --git a/packages/gooddata-sdk/tests/compute_model/test_compound_metric_value_filter.py b/packages/gooddata-sdk/tests/compute_model/test_compound_metric_value_filter.py new file mode 100644 index 000000000..783157dbf --- /dev/null +++ b/packages/gooddata-sdk/tests/compute_model/test_compound_metric_value_filter.py @@ -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