From 0f652531d17a2bb99d871d09e9970b8bd98fd954 Mon Sep 17 00:00:00 2001 From: Auto Pipeline Date: Tue, 17 Mar 2026 13:03:33 +0000 Subject: [PATCH] feat(gooddata-sdk): [AUTO] add MatchAttributeFilter SDK wrapper for LX-2032 Add MatchAttributeFilter class to compute/model/filter.py with label, literal, match_type (STARTS_WITH/ENDS_WITH/CONTAINS), negate, case_sensitive fields and as_api_model() serialization. Add matchAttributeFilter deserialization branch in ComputeToSdkConverter.convert_filter(). Export MatchAttributeFilter and AttributeFilterMatchType from public API. Co-Authored-By: Claude Sonnet 4.6 --- .../gooddata-sdk/src/gooddata_sdk/__init__.py | 2 + .../compute/compute_to_sdk_converter.py | 13 +++ .../src/gooddata_sdk/compute/model/filter.py | 84 +++++++++++++++++++ .../compute/test_compute_to_sdk_converter.py | 61 ++++++++++++++ .../compute_model/test_attribute_filters.py | 54 +++++++++++- 5 files changed, 213 insertions(+), 1 deletion(-) diff --git a/packages/gooddata-sdk/src/gooddata_sdk/__init__.py b/packages/gooddata-sdk/src/gooddata_sdk/__init__.py index fba74d7f0..4690c2854 100644 --- a/packages/gooddata-sdk/src/gooddata_sdk/__init__.py +++ b/packages/gooddata-sdk/src/gooddata_sdk/__init__.py @@ -273,10 +273,12 @@ AllMetricValueFilter, AllTimeDateFilter, AttributeFilter, + AttributeFilterMatchType, BoundedFilter, CompoundMetricValueFilter, Filter, InlineFilter, + MatchAttributeFilter, MetricValueComparisonCondition, MetricValueFilter, MetricValueRangeCondition, 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 0ce15f2e4..6cfba8618 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 @@ -10,6 +10,7 @@ CompoundMetricValueFilter, Filter, InlineFilter, + MatchAttributeFilter, MetricValueComparisonCondition, MetricValueFilter, MetricValueRangeCondition, @@ -75,6 +76,18 @@ def convert_filter(filter_dict: dict[str, Any]) -> Filter: f = filter_dict["negativeAttributeFilter"] return NegativeAttributeFilter(label=ref_extract(f["label"]), values=f["notIn"]["values"]) + if "matchAttributeFilter" in filter_dict: + f = filter_dict["matchAttributeFilter"] + return MatchAttributeFilter( + label=ref_extract(f["label"]), + literal=f["literal"], + match_type=f["matchType"], + negate=f.get("negate", False), + case_sensitive=f.get("caseSensitive", True), + local_identifier=f.get("localIdentifier"), + apply_on_result=f.get("applyOnResult"), + ) + if "relativeDateFilter" in filter_dict: f = filter_dict["relativeDateFilter"] 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 2cc061b47..d456ecca0 100644 --- a/packages/gooddata-sdk/src/gooddata_sdk/compute/model/filter.py +++ b/packages/gooddata-sdk/src/gooddata_sdk/compute/model/filter.py @@ -24,6 +24,7 @@ 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 +from gooddata_api_client.models import MatchAttributeFilterMatchAttributeFilter as MatchAttributeFilterBody from gooddata_api_client.models import RankingFilterRankingFilter as RankingFilterBody from gooddata_api_client.models import RelativeDateFilterRelativeDateFilter as RelativeDateFilterBody @@ -78,6 +79,8 @@ EmptyValueHandling: TypeAlias = Literal["INCLUDE", "EXCLUDE", "ONLY"] +AttributeFilterMatchType: TypeAlias = Literal["STARTS_WITH", "ENDS_WITH", "CONTAINS"] + def _extract_id_or_local_id(val: Union[ObjId, Attribute, Metric, str]) -> Union[ObjId, str]: if isinstance(val, (str, ObjId)): @@ -152,6 +155,87 @@ def description(self, labels: dict[str, str], format_locale: str | None = None) return f"{labels.get(label_id, label_id)}: {values}" +class MatchAttributeFilter(Filter): + def __init__( + self, + label: Union[ObjId, str, Attribute], + literal: str, + match_type: AttributeFilterMatchType, + negate: bool = False, + case_sensitive: bool = True, + local_identifier: str | None = None, + apply_on_result: bool | None = None, + ) -> None: + super().__init__() + + if match_type not in ("STARTS_WITH", "ENDS_WITH", "CONTAINS"): + raise ValueError( + f"Invalid match attribute filter match type '{match_type}'. " + "It is expected to be one of: STARTS_WITH, ENDS_WITH, CONTAINS" + ) + + self._label = _extract_id_or_local_id(label) + self._literal = literal + self._match_type = match_type + self._negate = negate + self._case_sensitive = case_sensitive + self._local_identifier = local_identifier + self._apply_on_result = apply_on_result + + @property + def label(self) -> Union[ObjId, str]: + return self._label + + @property + def literal(self) -> str: + return self._literal + + @property + def match_type(self) -> AttributeFilterMatchType: + return self._match_type + + @property + def negate(self) -> bool: + return self._negate + + @property + def case_sensitive(self) -> bool: + return self._case_sensitive + + @property + def local_identifier(self) -> str | None: + return self._local_identifier + + @property + def apply_on_result(self) -> bool | None: + return self._apply_on_result + + def is_noop(self) -> bool: + return False + + def as_api_model(self) -> afm_models.MatchAttributeFilter: + label_id = _to_identifier(self._label) + body_params: dict[str, Any] = { + "label": label_id, + "literal": self._literal, + "match_type": self._match_type, + "negate": self._negate, + "case_sensitive": self._case_sensitive, + "_check_type": False, + } + if self._local_identifier is not None: + body_params["local_identifier"] = self._local_identifier + if self._apply_on_result is not None: + body_params["apply_on_result"] = self._apply_on_result + body = MatchAttributeFilterBody(**body_params) + return afm_models.MatchAttributeFilter(body, _check_type=False) + + def description(self, labels: dict[str, str], format_locale: str | None = None) -> str: + label_id = self._label.id if isinstance(self._label, ObjId) else self._label + negate_str = "not " if self._negate else "" + return f"{labels.get(label_id, label_id)}: {negate_str}{self._match_type.lower()} {self._literal}" + + _GRANULARITY: set[str] = { "YEAR", "QUARTER", 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 2ee2d5b2e..e5784dcb6 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 @@ -8,6 +8,7 @@ CompoundMetricValueFilter, ComputeToSdkConverter, InlineFilter, + MatchAttributeFilter, MetricValueComparisonCondition, MetricValueFilter, MetricValueRangeCondition, @@ -265,6 +266,66 @@ def test_ranking_filter_with_dimensionality_conversion(): assert result.value == 5 +def test_match_attribute_filter_conversion(): + filter_dict = json.loads( + """ + { + "matchAttributeFilter": { + "label": { + "identifier": { "id": "attribute1", "type": "label" } + }, + "literal": "foo", + "matchType": "STARTS_WITH", + "negate": false, + "caseSensitive": true + } + } + """ + ) + + result = ComputeToSdkConverter.convert_filter(filter_dict) + + assert isinstance(result, MatchAttributeFilter) + assert result.label.id == "attribute1" + assert result.literal == "foo" + assert result.match_type == "STARTS_WITH" + assert result.negate is False + assert result.case_sensitive is True + assert result.local_identifier is None + assert result.apply_on_result is None + + +def test_match_attribute_filter_conversion_with_optional_fields(): + filter_dict = json.loads( + """ + { + "matchAttributeFilter": { + "label": { + "identifier": { "id": "attribute2", "type": "label" } + }, + "literal": "bar", + "matchType": "CONTAINS", + "negate": true, + "caseSensitive": false, + "localIdentifier": "my_filter", + "applyOnResult": true + } + } + """ + ) + + result = ComputeToSdkConverter.convert_filter(filter_dict) + + assert isinstance(result, MatchAttributeFilter) + assert result.label.id == "attribute2" + assert result.literal == "bar" + assert result.match_type == "CONTAINS" + assert result.negate is True + assert result.case_sensitive is False + assert result.local_identifier == "my_filter" + assert result.apply_on_result is True + + def test_inline_filter(): filter_dict = json.loads( """ diff --git a/packages/gooddata-sdk/tests/compute_model/test_attribute_filters.py b/packages/gooddata-sdk/tests/compute_model/test_attribute_filters.py index 2e85fb899..da1ad1e6f 100644 --- a/packages/gooddata-sdk/tests/compute_model/test_attribute_filters.py +++ b/packages/gooddata-sdk/tests/compute_model/test_attribute_filters.py @@ -5,7 +5,7 @@ import os import pytest -from gooddata_sdk import NegativeAttributeFilter, ObjId, PositiveAttributeFilter +from gooddata_sdk import MatchAttributeFilter, NegativeAttributeFilter, ObjId, PositiveAttributeFilter _current_dir = os.path.dirname(os.path.abspath(__file__)) @@ -76,3 +76,55 @@ def test_empty_positive_filter_is_not_noop(): f = PositiveAttributeFilter(label="test") assert f.is_noop() is False + + +def test_match_attribute_filter_starts_with(): + f = MatchAttributeFilter(label="local_id", literal="foo", match_type="STARTS_WITH") + model = f.as_api_model() + d = model.to_dict() + inner = d["match_attribute_filter"] + assert inner["label"] == {"local_identifier": "local_id"} + assert inner["literal"] == "foo" + assert inner["match_type"] == "STARTS_WITH" + assert inner["negate"] is False + assert inner["case_sensitive"] is True + + +def test_match_attribute_filter_ends_with_obj_id(): + f = MatchAttributeFilter( + label=ObjId(type="label", id="label.id"), + literal="bar", + match_type="ENDS_WITH", + ) + model = f.as_api_model() + d = model.to_dict() + inner = d["match_attribute_filter"] + assert inner["label"] == {"identifier": {"id": "label.id", "type": "label"}} + assert inner["literal"] == "bar" + assert inner["match_type"] == "ENDS_WITH" + + +def test_match_attribute_filter_contains_negate_case_insensitive(): + f = MatchAttributeFilter( + label="local_id", + literal="baz", + match_type="CONTAINS", + negate=True, + case_sensitive=False, + ) + model = f.as_api_model() + d = model.to_dict() + inner = d["match_attribute_filter"] + assert inner["match_type"] == "CONTAINS" + assert inner["negate"] is True + assert inner["case_sensitive"] is False + + +def test_match_attribute_filter_is_not_noop(): + f = MatchAttributeFilter(label="local_id", literal="x", match_type="STARTS_WITH") + assert f.is_noop() is False + + +def test_match_attribute_filter_invalid_match_type(): + with pytest.raises(ValueError, match="STARTS_WITH"): + MatchAttributeFilter(label="local_id", literal="x", match_type="INVALID")