Skip to content

Commit eadaa4b

Browse files
committed
feat: Add CompoundMeasureValueFilter
Adding CompoundMeasureValueFilter definition, which can be used to define several numerical conditions in a single filter. JIRA: LX-2073 risk: low
1 parent 3b046b5 commit eadaa4b

File tree

5 files changed

+202
-0
lines changed

5 files changed

+202
-0
lines changed

packages/gooddata-sdk/src/gooddata_sdk/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,9 +254,12 @@
254254
AllTimeFilter,
255255
AttributeFilter,
256256
BoundedFilter,
257+
CompoundMetricValueFilter,
257258
Filter,
258259
InlineFilter,
260+
MetricValueComparisonCondition,
259261
MetricValueFilter,
262+
MetricValueRangeCondition,
260263
NegativeAttributeFilter,
261264
PositiveAttributeFilter,
262265
RankingFilter,

packages/gooddata-sdk/src/gooddata_sdk/compute/compute_to_sdk_converter.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,12 @@
77
AbsoluteDateFilter,
88
AllTimeFilter,
99
BoundedFilter,
10+
CompoundMetricValueFilter,
1011
Filter,
1112
InlineFilter,
13+
MetricValueComparisonCondition,
1214
MetricValueFilter,
15+
MetricValueRangeCondition,
1316
NegativeAttributeFilter,
1417
PositiveAttributeFilter,
1518
RankingFilter,
@@ -116,6 +119,28 @@ def convert_filter(filter_dict: dict[str, Any]) -> Filter:
116119
treat_nulls_as=f.get("treatNullValuesAs"),
117120
)
118121

122+
if "compoundMeasureValueFilter" in filter_dict:
123+
f = filter_dict["compoundMeasureValueFilter"]
124+
125+
conditions: list[Union[MetricValueComparisonCondition, MetricValueRangeCondition]] = []
126+
for condition in f.get("conditions", []):
127+
if "comparison" in condition:
128+
c = condition["comparison"]
129+
conditions.append(MetricValueComparisonCondition(operator=c["operator"], value=c["value"]))
130+
elif "range" in condition:
131+
c = condition["range"]
132+
conditions.append(
133+
MetricValueRangeCondition(operator=c["operator"], from_value=c["from"], to_value=c["to"])
134+
)
135+
else:
136+
raise ValueError(f"Unsupported measure value condition type: {condition}")
137+
138+
return CompoundMetricValueFilter(
139+
metric=ref_extract(f["measure"]),
140+
conditions=conditions,
141+
treat_nulls_as=f.get("treatNullValuesAs"),
142+
)
143+
119144
if "rankingFilter" in filter_dict:
120145
f = filter_dict["rankingFilter"]
121146

packages/gooddata-sdk/src/gooddata_sdk/compute/model/filter.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@
1717
from gooddata_api_client.models import (
1818
ComparisonMeasureValueFilterComparisonMeasureValueFilter as ComparisonMeasureValueFilterBody,
1919
)
20+
from gooddata_api_client.models import (
21+
CompoundMeasureValueFilterCompoundMeasureValueFilter as CompoundMeasureValueFilterBody,
22+
)
2023
from gooddata_api_client.models import NegativeAttributeFilterNegativeAttributeFilter as NegativeAttributeFilterBody
2124
from gooddata_api_client.models import PositiveAttributeFilterPositiveAttributeFilter as PositiveAttributeFilterBody
2225
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
483486
)
484487

485488

489+
@attrs.define(frozen=True, slots=True)
490+
class MetricValueComparisonCondition:
491+
operator: str
492+
value: Union[int, float]
493+
494+
def as_api_model(self) -> afm_models.MeasureValueCondition:
495+
comparison = afm_models.ComparisonConditionComparison(
496+
operator=self.operator,
497+
value=float(self.value),
498+
_check_type=False,
499+
)
500+
return afm_models.MeasureValueCondition(comparison=comparison, _check_type=False)
501+
502+
def description(self) -> str:
503+
return f"{_METRIC_VALUE_FILTER_OPERATOR_LABEL.get(self.operator, self.operator)} {float(self.value)}"
504+
505+
506+
@attrs.define(frozen=True, slots=True)
507+
class MetricValueRangeCondition:
508+
operator: str
509+
from_value: Union[int, float]
510+
to_value: Union[int, float]
511+
512+
def as_api_model(self) -> afm_models.MeasureValueCondition:
513+
range_body = afm_models.RangeConditionRange(
514+
_from=float(self.from_value),
515+
operator=self.operator,
516+
to=float(self.to_value),
517+
_check_type=False,
518+
)
519+
return afm_models.MeasureValueCondition(range=range_body, _check_type=False)
520+
521+
def description(self) -> str:
522+
not_between = "not" if self.operator == "NOT_BETWEEN" else ""
523+
return f"{not_between}between {float(self.from_value)} - {float(self.to_value)}"
524+
525+
526+
MetricValueCondition = Union[MetricValueComparisonCondition, MetricValueRangeCondition]
527+
528+
529+
class CompoundMetricValueFilter(Filter):
530+
"""
531+
Compound measure value filter.
532+
533+
Semantics match backend `CompoundMeasureValueFilter`: multiple conditions combined with OR logic.
534+
535+
Note:
536+
- If `conditions` is empty, the filter is a noop (all rows are returned).
537+
- `treat_nulls_as` is applied at the filter level (same for all conditions).
538+
"""
539+
540+
def __init__(
541+
self,
542+
metric: Union[ObjId, str, Metric],
543+
conditions: list[MetricValueCondition],
544+
treat_nulls_as: Union[float, None] = None,
545+
) -> None:
546+
super().__init__()
547+
self._metric = _extract_id_or_local_id(metric)
548+
self._conditions = conditions
549+
self._treat_nulls_as = treat_nulls_as
550+
551+
@property
552+
def metric(self) -> Union[ObjId, str]:
553+
return self._metric
554+
555+
@property
556+
def conditions(self) -> list[MetricValueCondition]:
557+
return self._conditions
558+
559+
@property
560+
def treat_nulls_as(self) -> Union[float, None]:
561+
return self._treat_nulls_as
562+
563+
def is_noop(self) -> bool:
564+
return len(self.conditions) == 0
565+
566+
def as_api_model(self) -> afm_models.CompoundMeasureValueFilter:
567+
measure = _to_identifier(self._metric)
568+
569+
kwargs: dict[str, Any] = dict(
570+
measure=measure,
571+
conditions=[c.as_api_model() for c in self.conditions],
572+
_check_type=False,
573+
)
574+
if self.treat_nulls_as is not None:
575+
kwargs["treat_null_values_as"] = self.treat_nulls_as
576+
577+
body = CompoundMeasureValueFilterBody(**kwargs)
578+
return afm_models.CompoundMeasureValueFilter(body, _check_type=False)
579+
580+
def description(self, labels: dict[str, str]) -> str:
581+
metric_id = self.metric.id if isinstance(self.metric, ObjId) else self.metric
582+
if not self.conditions:
583+
return f"{labels.get(metric_id, metric_id)}: All"
584+
conditions_str = " OR ".join([c.description() for c in self.conditions])
585+
return f"{labels.get(metric_id, metric_id)}: {conditions_str}"
586+
587+
486588
_RANKING_OPERATORS = {"TOP", "BOTTOM"}
487589

488590

packages/gooddata-sdk/tests/compute/test_compute_to_sdk_converter.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,12 @@
55
AbsoluteDateFilter,
66
ArithmeticMetric,
77
Attribute,
8+
CompoundMetricValueFilter,
89
ComputeToSdkConverter,
910
InlineFilter,
11+
MetricValueComparisonCondition,
1012
MetricValueFilter,
13+
MetricValueRangeCondition,
1114
NegativeAttributeFilter,
1215
PopDateMetric,
1316
PopDatesetMetric,
@@ -180,6 +183,38 @@ def test_range_measure_value_filter_conversion():
180183
assert result.treat_nulls_as == 42
181184

182185

186+
def test_compound_measure_value_filter_conversion():
187+
filter_dict = json.loads(
188+
"""
189+
{
190+
"compoundMeasureValueFilter": {
191+
"measure": { "localIdentifier": "measureLocalId" },
192+
"conditions": [
193+
{ "comparison": { "operator": "GREATER_THAN", "value": 100 } },
194+
{ "range": { "operator": "BETWEEN", "from": 10, "to": 20 } }
195+
],
196+
"treatNullValuesAs": 0,
197+
"applyOnResult": true
198+
}
199+
}
200+
"""
201+
)
202+
203+
result = ComputeToSdkConverter.convert_filter(filter_dict)
204+
205+
assert isinstance(result, CompoundMetricValueFilter)
206+
assert result.metric == "measureLocalId"
207+
assert result.treat_nulls_as == 0
208+
assert len(result.conditions) == 2
209+
assert isinstance(result.conditions[0], MetricValueComparisonCondition)
210+
assert result.conditions[0].operator == "GREATER_THAN"
211+
assert result.conditions[0].value == 100
212+
assert isinstance(result.conditions[1], MetricValueRangeCondition)
213+
assert result.conditions[1].operator == "BETWEEN"
214+
assert result.conditions[1].from_value == 10
215+
assert result.conditions[1].to_value == 20
216+
217+
183218
def test_ranking_filter_conversion():
184219
filter_dict = json.loads(
185220
"""
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# (C) 2026 GoodData Corporation
2+
from __future__ import annotations
3+
4+
from gooddata_sdk import (
5+
CompoundMetricValueFilter,
6+
MetricValueComparisonCondition,
7+
MetricValueRangeCondition,
8+
)
9+
from gooddata_sdk.compute.model.base import ObjId
10+
11+
12+
def test_compound_metric_value_filter_to_api_model():
13+
f = CompoundMetricValueFilter(
14+
metric="local_id1",
15+
conditions=[
16+
MetricValueComparisonCondition(operator="GREATER_THAN", value=10),
17+
MetricValueRangeCondition(operator="BETWEEN", from_value=2, to_value=3),
18+
],
19+
treat_nulls_as=0,
20+
)
21+
22+
assert f.is_noop() is False
23+
assert f.as_api_model().to_dict() == {
24+
"compound_measure_value_filter": {
25+
"conditions": [
26+
{"comparison": {"operator": "GREATER_THAN", "value": 10.0}},
27+
{"range": {"_from": 2.0, "operator": "BETWEEN", "to": 3.0}},
28+
],
29+
"measure": {"local_identifier": "local_id1"},
30+
"treat_null_values_as": 0,
31+
}
32+
}
33+
34+
35+
def test_compound_metric_value_filter_noop_when_no_conditions():
36+
f = CompoundMetricValueFilter(metric=ObjId(type="metric", id="metric.id"), conditions=[])
37+
assert f.is_noop() is True

0 commit comments

Comments
 (0)