Skip to content

Commit 4f29683

Browse files
committed
feat(gooddata-sdk): [AUTO] Add AFM execution parameters, WhatIfScenario, and new filter types
1 parent 4cb8139 commit 4f29683

4 files changed

Lines changed: 293 additions & 1 deletion

File tree

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,7 @@
280280
ExecutionDefinition,
281281
ExecutionResponse,
282282
ExecutionResult,
283+
MetricDefinitionOverride,
283284
ResultCacheMetadata,
284285
ResultSizeBytesLimitExceeded,
285286
ResultSizeDimensions,
@@ -316,6 +317,11 @@
316317
PopDatesetMetric,
317318
SimpleMetric,
318319
)
320+
from gooddata_sdk.compute.model.what_if import (
321+
AfmWhatIfMeasureAdjustmentConfig,
322+
AfmWhatIfScenarioConfig,
323+
AfmWhatIfScenarioItem,
324+
)
319325
from gooddata_sdk.compute.service import ComputeService
320326
from gooddata_sdk.sdk import GoodDataSdk
321327
from gooddata_sdk.table import ExecutionTable, TableService

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

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,49 @@
2929
logger = logging.getLogger(__name__)
3030

3131

32+
@define(kw_only=True)
33+
class MetricDefinitionOverride:
34+
"""(EXPERIMENTAL) Override for a catalog metric definition used during execution.
35+
36+
Allows substituting a catalog metric's MAQL definition for a single
37+
computation request without modifying the stored catalog definition.
38+
39+
Args:
40+
item_id: ID of the catalog metric whose definition is being overridden.
41+
item_type: Type of the catalog item. One of ``"attribute"``, ``"label"``,
42+
``"fact"``, or ``"metric"``.
43+
maql: MAQL expression to use instead of the stored definition.
44+
"""
45+
46+
item_id: str
47+
item_type: str
48+
maql: str
49+
50+
def as_api_model(self) -> models.MetricDefinitionOverride:
51+
identifier = models.AfmObjectIdentifierCoreIdentifier(
52+
id=self.item_id,
53+
type=self.item_type,
54+
_check_type=False,
55+
)
56+
item = models.AfmObjectIdentifierCore(
57+
identifier=identifier,
58+
_check_type=False,
59+
)
60+
inline = models.InlineMeasureDefinitionInline(
61+
maql=self.maql,
62+
_check_type=False,
63+
)
64+
definition = models.InlineMeasureDefinition(
65+
inline=inline,
66+
_check_type=False,
67+
)
68+
return models.MetricDefinitionOverride(
69+
item=item,
70+
definition=definition,
71+
_check_type=False,
72+
)
73+
74+
3275
@define
3376
class TotalDimension:
3477
idx: int
@@ -72,13 +115,15 @@ def __init__(
72115
dimensions: list[TableDimension],
73116
totals: list[TotalDefinition] | None = None,
74117
is_cancellable: bool = False,
118+
measure_definition_overrides: list[MetricDefinitionOverride] | None = None,
75119
) -> None:
76120
self._attributes = attributes or []
77121
self._metrics = metrics or []
78122
self._filters = filters or []
79123
self._dimensions = [dim for dim in dimensions if dim.item_ids is not None]
80124
self._totals = totals
81125
self._is_cancellable = is_cancellable
126+
self._measure_definition_overrides = measure_definition_overrides or []
82127

83128
@property
84129
def attributes(self) -> list[Attribute]:
@@ -115,6 +160,10 @@ def is_two_dim(self) -> bool:
115160
def is_cancellable(self) -> bool:
116161
return self._is_cancellable
117162

163+
@property
164+
def measure_definition_overrides(self) -> list[MetricDefinitionOverride]:
165+
return self._measure_definition_overrides
166+
118167
def _create_value_sort_key(self, sort_key: dict) -> models.SortKey:
119168
sort_key_value = sort_key["value"]
120169
return models.SortKey(
@@ -209,7 +258,14 @@ def _create_result_spec(self) -> models.ResultSpec:
209258
return models.ResultSpec(dimensions=dimensions, totals=totals)
210259

211260
def as_api_model(self) -> models.AfmExecution:
212-
execution = compute_model_to_api_model(attributes=self.attributes, metrics=self.metrics, filters=self.filters)
261+
execution = compute_model_to_api_model(
262+
attributes=self.attributes,
263+
metrics=self.metrics,
264+
filters=self.filters,
265+
measure_definition_overrides=self.measure_definition_overrides
266+
if self.measure_definition_overrides
267+
else None,
268+
)
213269
result_spec = self._create_result_spec()
214270

215271
return models.AfmExecution(execution=execution, result_spec=result_spec)
@@ -568,6 +624,7 @@ def compute_model_to_api_model(
568624
attributes: list[Attribute] | None = None,
569625
metrics: list[Metric] | None = None,
570626
filters: list[Filter] | None = None,
627+
measure_definition_overrides: list[MetricDefinitionOverride] | None = None,
571628
) -> models.AFM:
572629
"""
573630
Transforms categorized execution model entities (attributes, metrics, facts) into an API model
@@ -576,9 +633,16 @@ def compute_model_to_api_model(
576633
:param attributes: optionally specify list of attributes
577634
:param metrics: optionally specify list of metrics
578635
:param filters: optionally specify list of filters
636+
:param measure_definition_overrides: optionally specify metric definition overrides
579637
"""
638+
kwargs: dict[str, Any] = {}
639+
if measure_definition_overrides:
640+
kwargs["measure_definition_overrides"] = [o.as_api_model() for o in measure_definition_overrides]
641+
580642
return models.AFM(
581643
attributes=[a.as_api_model() for a in attributes] if attributes is not None else [],
582644
measures=[m.as_api_model() for m in metrics] if metrics is not None else [],
583645
filters=[f.as_api_model() for f in filters if not f.is_noop()] if filters is not None else [],
646+
_check_type=False,
647+
**kwargs,
584648
)
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# (C) 2025 GoodData Corporation
2+
from __future__ import annotations
3+
4+
import gooddata_api_client.models as afm_models
5+
from attrs import define, field
6+
7+
8+
@define(kw_only=True)
9+
class AfmWhatIfMeasureAdjustmentConfig:
10+
"""SDK wrapper for a single measure adjustment within a what-if scenario.
11+
12+
Represents an alternative MAQL definition for a catalog metric or fact that
13+
is used only during the current what-if computation without modifying the
14+
stored definition.
15+
"""
16+
17+
metric_id: str
18+
"""ID of the metric or fact to adjust."""
19+
metric_type: str
20+
"""Type of the object being adjusted. Typically 'metric' or 'fact'."""
21+
scenario_maql: str
22+
"""Alternative MAQL expression to use for this scenario."""
23+
24+
def as_api_model(self) -> afm_models.WhatIfMeasureAdjustmentConfig:
25+
return afm_models.WhatIfMeasureAdjustmentConfig(
26+
metric_id=self.metric_id,
27+
metric_type=self.metric_type,
28+
scenario_maql=self.scenario_maql,
29+
_check_type=False,
30+
)
31+
32+
33+
@define(kw_only=True)
34+
class AfmWhatIfScenarioItem:
35+
"""SDK wrapper for a single what-if scenario.
36+
37+
Represents one named scenario that overrides one or more measure definitions
38+
with alternative MAQL expressions.
39+
"""
40+
41+
label: str
42+
"""Human-readable label for the scenario."""
43+
adjustments: list[AfmWhatIfMeasureAdjustmentConfig] = field(factory=list)
44+
"""Measure adjustments for this scenario."""
45+
46+
def as_api_model(self) -> afm_models.WhatIfScenarioItem:
47+
return afm_models.WhatIfScenarioItem(
48+
label=self.label,
49+
adjustments=[a.as_api_model() for a in self.adjustments],
50+
_check_type=False,
51+
)
52+
53+
54+
@define(kw_only=True)
55+
class AfmWhatIfScenarioConfig:
56+
"""SDK wrapper for what-if scenario analysis configuration.
57+
58+
Passed as part of :class:`AfmVisualizationConfig` to trigger what-if
59+
computation alongside a regular AFM execution.
60+
"""
61+
62+
include_baseline: bool
63+
"""Whether the unmodified (baseline) values are included in the result."""
64+
scenarios: list[AfmWhatIfScenarioItem] = field(factory=list)
65+
"""Scenarios, each providing alternative measure calculations."""
66+
67+
def as_api_model(self) -> afm_models.WhatIfScenarioConfig:
68+
return afm_models.WhatIfScenarioConfig(
69+
include_baseline=self.include_baseline,
70+
scenarios=[s.as_api_model() for s in self.scenarios],
71+
_check_type=False,
72+
)
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
# (C) 2025 GoodData Corporation
2+
from __future__ import annotations
3+
4+
import pytest
5+
from gooddata_sdk.compute.model.base import ObjId
6+
from gooddata_sdk.compute.model.execution import MetricDefinitionOverride, compute_model_to_api_model
7+
from gooddata_sdk.compute.model.metric import SimpleMetric
8+
from gooddata_sdk.compute.model.what_if import (
9+
AfmWhatIfMeasureAdjustmentConfig,
10+
AfmWhatIfScenarioConfig,
11+
AfmWhatIfScenarioItem,
12+
)
13+
14+
15+
class TestMetricDefinitionOverride:
16+
def test_as_api_model_produces_correct_structure(self):
17+
override = MetricDefinitionOverride(
18+
item_id="my.metric",
19+
item_type="metric",
20+
maql="SELECT SUM({fact/revenue}) WHERE {attribute/region} = 'West'",
21+
)
22+
api_model = override.as_api_model()
23+
result = api_model.to_dict()
24+
25+
assert result["item"]["identifier"]["id"] == "my.metric"
26+
assert result["item"]["identifier"]["type"] == "metric"
27+
assert result["definition"]["inline"]["maql"] == (
28+
"SELECT SUM({fact/revenue}) WHERE {attribute/region} = 'West'"
29+
)
30+
31+
def test_as_api_model_with_fact_type(self):
32+
override = MetricDefinitionOverride(
33+
item_id="revenue.fact",
34+
item_type="fact",
35+
maql="SELECT AVG({fact/revenue})",
36+
)
37+
api_model = override.as_api_model()
38+
result = api_model.to_dict()
39+
40+
assert result["item"]["identifier"]["type"] == "fact"
41+
42+
43+
class TestComputeModelToApiModelWithOverrides:
44+
def test_measure_definition_overrides_forwarded(self):
45+
metric = SimpleMetric(local_id="m1", item=ObjId(type="metric", id="catalog.metric"))
46+
override = MetricDefinitionOverride(
47+
item_id="catalog.metric",
48+
item_type="metric",
49+
maql="SELECT SUM({fact/cost})",
50+
)
51+
52+
afm = compute_model_to_api_model(
53+
metrics=[metric],
54+
measure_definition_overrides=[override],
55+
)
56+
result = afm.to_dict()
57+
58+
assert "measureDefinitionOverrides" in result
59+
overrides = result["measureDefinitionOverrides"]
60+
assert len(overrides) == 1
61+
assert overrides[0]["item"]["identifier"]["id"] == "catalog.metric"
62+
assert overrides[0]["definition"]["inline"]["maql"] == "SELECT SUM({fact/cost})"
63+
64+
def test_no_overrides_omits_field(self):
65+
metric = SimpleMetric(local_id="m1", item=ObjId(type="metric", id="catalog.metric"))
66+
afm = compute_model_to_api_model(metrics=[metric])
67+
result = afm.to_dict()
68+
69+
assert result.get("measureDefinitionOverrides") is None or result.get("measureDefinitionOverrides") == []
70+
71+
72+
class TestAfmWhatIfMeasureAdjustmentConfig:
73+
def test_as_api_model_produces_correct_structure(self):
74+
adjustment = AfmWhatIfMeasureAdjustmentConfig(
75+
metric_id="revenue",
76+
metric_type="metric",
77+
scenario_maql="SELECT SUM({fact/revenue}) * 1.1",
78+
)
79+
api_model = adjustment.as_api_model()
80+
result = api_model.to_dict()
81+
82+
assert result["metricId"] == "revenue"
83+
assert result["metricType"] == "metric"
84+
assert result["scenarioMaql"] == "SELECT SUM({fact/revenue}) * 1.1"
85+
86+
87+
class TestAfmWhatIfScenarioItem:
88+
def test_as_api_model_with_adjustments(self):
89+
adjustment = AfmWhatIfMeasureAdjustmentConfig(
90+
metric_id="revenue",
91+
metric_type="metric",
92+
scenario_maql="SELECT SUM({fact/revenue}) * 1.1",
93+
)
94+
scenario = AfmWhatIfScenarioItem(
95+
label="Optimistic +10%",
96+
adjustments=[adjustment],
97+
)
98+
api_model = scenario.as_api_model()
99+
result = api_model.to_dict()
100+
101+
assert result["label"] == "Optimistic +10%"
102+
assert len(result["adjustments"]) == 1
103+
assert result["adjustments"][0]["metricId"] == "revenue"
104+
105+
def test_as_api_model_empty_adjustments(self):
106+
scenario = AfmWhatIfScenarioItem(label="Empty scenario")
107+
result = scenario.as_api_model().to_dict()
108+
109+
assert result["label"] == "Empty scenario"
110+
assert result["adjustments"] == []
111+
112+
113+
class TestAfmWhatIfScenarioConfig:
114+
def test_as_api_model_with_scenarios(self):
115+
adjustment = AfmWhatIfMeasureAdjustmentConfig(
116+
metric_id="revenue",
117+
metric_type="metric",
118+
scenario_maql="SELECT SUM({fact/revenue}) * 0.9",
119+
)
120+
scenario = AfmWhatIfScenarioItem(label="Pessimistic -10%", adjustments=[adjustment])
121+
config = AfmWhatIfScenarioConfig(include_baseline=True, scenarios=[scenario])
122+
123+
result = config.as_api_model().to_dict()
124+
125+
assert result["includeBaseline"] is True
126+
assert len(result["scenarios"]) == 1
127+
assert result["scenarios"][0]["label"] == "Pessimistic -10%"
128+
129+
def test_as_api_model_no_baseline(self):
130+
config = AfmWhatIfScenarioConfig(include_baseline=False)
131+
result = config.as_api_model().to_dict()
132+
133+
assert result["includeBaseline"] is False
134+
assert result["scenarios"] == []
135+
136+
@pytest.mark.parametrize(
137+
"include_baseline, scenario_count",
138+
[
139+
(True, 0),
140+
(False, 1),
141+
(True, 2),
142+
],
143+
)
144+
def test_as_api_model_parametrized(self, include_baseline: bool, scenario_count: int):
145+
scenarios = [AfmWhatIfScenarioItem(label=f"scenario_{i}") for i in range(scenario_count)]
146+
config = AfmWhatIfScenarioConfig(include_baseline=include_baseline, scenarios=scenarios)
147+
result = config.as_api_model().to_dict()
148+
149+
assert result["includeBaseline"] == include_baseline
150+
assert len(result["scenarios"]) == scenario_count

0 commit comments

Comments
 (0)