From c5cd9c9124ac8821fb5d89e0a7695a999fcccb01 Mon Sep 17 00:00:00 2001 From: Roman Valovic Date: Mon, 12 Oct 2026 13:09:42 +0200 Subject: [PATCH 1/2] feat: migrate computed attributes with metrics Legacy computed attributes (attr.comp.X + label.comp.X) are fetched in the metrics step, translated to Cloud computedAttribute entities (SELECT CASE WHEN SUM({metric/..}) .. END BY {label/..} ALL OTHER) and deployed between plain metrics and metrics that reference them. Metrics, insights and dashboards now reference them as {computed_attribute/label.comp.X} / type "computedAttribute". risk: low Co-Authored-By: Claude Fable 5.1 --- README.md | 11 ++ .../backends/cloud/client.py | 22 ++++ .../backends/cloud/object_creator_strategy.py | 30 +++++ src/gooddata_legacy2cloud/constants.py | 1 + .../dashboards/drill_converter.py | 3 +- .../dashboards/filter_context.py | 11 +- src/gooddata_legacy2cloud/helpers.py | 18 +++ src/gooddata_legacy2cloud/id_mappings.py | 8 ++ .../insights/cloud_insight.py | 11 +- .../metrics/computed_attribute.py | 119 ++++++++++++++++++ .../metrics/data_classes.py | 1 + .../metrics/maql/content_tree_modifier.py | 9 +- src/gooddata_legacy2cloud/output_writer.py | 3 + .../workflows/migrate_metrics.py | 66 ++++++++-- .../legacy_objects/objects_by_uri.json | 45 +++++++ .../with_computed_attribute_cloud.json | 69 ++++++++++ .../with_computed_attribute_legacy.json | 63 ++++++++++ .../legacy_objects/computed_attribute.json | 56 +++++++++ .../legacy_objects/objects_by_uri.json | 20 +++ .../metrics/mapping_files/metric_mappings.csv | 1 + .../test_cases/computed_attribute_cloud.json | 19 +++ .../with_computed_attribute_cloud.json | 17 +++ .../with_computed_attribute_legacy.json | 63 ++++++++++ .../with_unresolved_identifier_cloud.json | 6 +- .../with_unresolved_identifier_legacy.json | 4 +- .../shared/mapping_files/ldm_mappings.csv | 1 + tests/test_computed_attributes.py | 65 ++++++++++ tests/test_insights.py | 1 + tests/test_metrics.py | 1 + 29 files changed, 724 insertions(+), 20 deletions(-) create mode 100644 src/gooddata_legacy2cloud/metrics/computed_attribute.py create mode 100644 tests/data/insights/test_cases/with_computed_attribute_cloud.json create mode 100644 tests/data/insights/test_cases/with_computed_attribute_legacy.json create mode 100644 tests/data/metrics/legacy_objects/computed_attribute.json create mode 100644 tests/data/metrics/test_cases/computed_attribute_cloud.json create mode 100644 tests/data/metrics/test_cases/with_computed_attribute_cloud.json create mode 100644 tests/data/metrics/test_cases/with_computed_attribute_legacy.json create mode 100644 tests/test_computed_attributes.py diff --git a/README.md b/README.md index 04ea8ba..40b28bd 100644 --- a/README.md +++ b/README.md @@ -377,11 +377,22 @@ Script generates: - MAQL transformation log to `metrics_maql.log` - Failed Cloud metrics to `cloud_failed_metrics.json` - Skipped objects IDs to `cloud_skipped_metrics.json` +- Computed attribute mappings to `computed_attribute_mappings.csv` +- Failed / skipped computed attributes to `cloud_failed_computed_attributes.json` / `cloud_skipped_computed_attributes.json` +- Computed attribute dumps to `legacy_computed_attributes.json` / `cloud_computed_attributes.json` (with `--dump-legacy` / `--dump-cloud`) Script reads: - LDM mappings from `ldm_mappings.csv` +Computed attributes: + +- Legacy computed attributes (`attr.comp.X` + `label.comp.X`) are migrated together with metrics as Cloud computed attributes with ID `label.comp.X`. The Legacy `CASE WHEN` definition is translated to `SELECT CASE WHEN SUM({metric/...}) <= n THEN "bucket" ... END BY {label/...} ALL OTHER`. +- Metrics, insights and dashboards reference them as `{computed_attribute/label.comp.X}` / `"type": "computedAttribute"`. +- Deployment order is: metrics → computed attributes → metrics using computed attributes. +- Requires the `enableComputedAttributes` feature flag in Cloud, on the parent workspace and on every child workspace that uses them (it is not inherited). Cloud computed attributes are in Beta: exports, date dimension and period-over-period are not supported with them. +- Only one-metric, numeric-threshold definitions are supported; other definitions are logged as errors and skipped. + Notes: - If any element values are missing within the Legacy metric, they are replaced with `--MISSING VALUE--`. Such metrics can be found in `metrics_maql.log` file and these metrics are also marked with [WARN] in their title (unless `--suppress-migration-warnings` is used). Warnings are always printed to console. diff --git a/src/gooddata_legacy2cloud/backends/cloud/client.py b/src/gooddata_legacy2cloud/backends/cloud/client.py index 5d49785..cc71f97 100644 --- a/src/gooddata_legacy2cloud/backends/cloud/client.py +++ b/src/gooddata_legacy2cloud/backends/cloud/client.py @@ -257,6 +257,28 @@ def update_metric(self, metric_id, metric): url = f"api/v1/entities/workspaces/{self.ws}/metrics/{metric_id}" return self._put(url, metric) + def get_computed_attributes(self, page=0, origin: str = "ALL"): + """Sends a GET request to the server to get all computed attributes.""" + url = f"api/v1/entities/workspaces/{self.ws}/computedAttributes?origin={origin}&size={CLOUD_REQUEST_PAGE_SIZE}&page={page}" + try: + result = self._get(url) + data = result["data"] + if len(data) == CLOUD_REQUEST_PAGE_SIZE: + data.extend(self.get_computed_attributes(page + 1)) + return data + except KeyError: + return [] + + def create_computed_attribute(self, computed_attribute): + """Post a computed attribute to the server.""" + url = f"api/v1/entities/workspaces/{self.ws}/computedAttributes" + return self._post(url, computed_attribute) + + def update_computed_attribute(self, computed_attribute_id, computed_attribute): + """Update a computed attribute on the server.""" + url = f"api/v1/entities/workspaces/{self.ws}/computedAttributes/{computed_attribute_id}" + return self._put(url, computed_attribute) + def remove_metric(self, metric_id): """Sends a DELETE request to the server.""" url = f"api/v1/entities/workspaces/{self.ws}/metrics/{metric_id}" diff --git a/src/gooddata_legacy2cloud/backends/cloud/object_creator_strategy.py b/src/gooddata_legacy2cloud/backends/cloud/object_creator_strategy.py index 093fcb8..599f638 100644 --- a/src/gooddata_legacy2cloud/backends/cloud/object_creator_strategy.py +++ b/src/gooddata_legacy2cloud/backends/cloud/object_creator_strategy.py @@ -27,6 +27,8 @@ CLOUD_FAILED_REPORTS_FILE = "cloud_failed_reports.json" CLOUD_FAILED_PP_DASHBOARDS_FILE = "cloud_failed_pp_dashboards.json" CLOUD_SKIPPED_METRICS_FILE = "cloud_skipped_metrics.json" +CLOUD_FAILED_COMPUTED_ATTRIBUTES_FILE = "cloud_failed_computed_attributes.json" +CLOUD_SKIPPED_COMPUTED_ATTRIBUTES_FILE = "cloud_skipped_computed_attributes.json" CLOUD_SKIPPED_INSIGHTS_FILE = "cloud_skipped_insights.json" CLOUD_SKIPPED_KPI_COMPARISON_INSIGHTS_FILE = ( "cloud_skipped_kpi_comparison_insights.json" @@ -147,6 +149,28 @@ def get_object_type(self) -> str: return "metric" +class ComputedAttributeCreationStrategy(CreationStrategy): + """Strategy for creating computed attributes""" + + def get_existing_objects(self) -> List[Dict]: + return self.cloud_client.get_computed_attributes() + + def create_object(self, obj: T) -> Any: + return self.cloud_client.create_computed_attribute(obj) + + def update_object(self, obj: T) -> Any: + return self.cloud_client.update_computed_attribute(obj["data"]["id"], obj) + + def disable_object(self, obj: T, response: Response) -> Any: + return obj # no placeholder for computed attributes + + def get_object_link(self, domain: str, workspace_id: str, obj_id: str) -> str: + return f"{domain}/api/v1/entities/workspaces/{workspace_id}/computedAttributes/{obj_id}" + + def get_object_type(self) -> str: + return "computed_attribute" + + class InsightCreationStrategy(CreationStrategy): """Strategy for creating insights""" @@ -463,6 +487,12 @@ class ObjectConfig(TypedDict): "skipped_file": CLOUD_SKIPPED_METRICS_FILE, "log_file_mode": "w", }, + "computed_attribute": { + "strategy_class": ComputedAttributeCreationStrategy, + "failed_file": CLOUD_FAILED_COMPUTED_ATTRIBUTES_FILE, + "skipped_file": CLOUD_SKIPPED_COMPUTED_ATTRIBUTES_FILE, + "log_file_mode": "w", + }, "insight": { "strategy_class": InsightCreationStrategy, "failed_file": CLOUD_FAILED_INSIGHTS_FILE, diff --git a/src/gooddata_legacy2cloud/constants.py b/src/gooddata_legacy2cloud/constants.py index 0e5617b..8ed3d66 100644 --- a/src/gooddata_legacy2cloud/constants.py +++ b/src/gooddata_legacy2cloud/constants.py @@ -10,6 +10,7 @@ LDM_MAPPING_FILE = "ldm_mappings.csv" METRIC_MAPPING_FILE = "metric_mappings.csv" +COMPUTED_ATTRIBUTE_MAPPING_FILE = "computed_attribute_mappings.csv" INSIGHT_MAPPING_FILE = "insight_mappings.csv" DASHBOARD_MAPPING_FILE = "dashboard_mappings.csv" REPORT_MAPPING_FILE = "report_mappings.csv" diff --git a/src/gooddata_legacy2cloud/dashboards/drill_converter.py b/src/gooddata_legacy2cloud/dashboards/drill_converter.py index 84a779c..97a04e3 100644 --- a/src/gooddata_legacy2cloud/dashboards/drill_converter.py +++ b/src/gooddata_legacy2cloud/dashboards/drill_converter.py @@ -11,6 +11,7 @@ from typing import Optional from gooddata_legacy2cloud.dashboards.data_classes import DashboardContext +from gooddata_legacy2cloud.helpers import cloud_display_form_type logger = logging.getLogger("migration") @@ -61,7 +62,7 @@ def _get_attribute_identifier(self, legacy_attribute_id: str) -> dict: return { "identifier": { "id": cloud_id, - "type": "label", # Most Legacy attributes map to Cloud labels + "type": cloud_display_form_type(legacy_attribute_id), } } diff --git a/src/gooddata_legacy2cloud/dashboards/filter_context.py b/src/gooddata_legacy2cloud/dashboards/filter_context.py index 752986f..a34345c 100644 --- a/src/gooddata_legacy2cloud/dashboards/filter_context.py +++ b/src/gooddata_legacy2cloud/dashboards/filter_context.py @@ -2,7 +2,7 @@ import logging from gooddata_legacy2cloud.dashboards.data_classes import DashboardContext -from gooddata_legacy2cloud.helpers import get_cloud_id +from gooddata_legacy2cloud.helpers import cloud_display_form_type, get_cloud_id from gooddata_legacy2cloud.metrics.attribute_element import AttributeElement from gooddata_legacy2cloud.metrics.contants import DELETED_VALUE from gooddata_legacy2cloud.models.cloud.filter_context import ( @@ -28,13 +28,18 @@ def __init__(self, ctx: DashboardContext, filter_context_uri: str): @staticmethod def _transform_filter_type_value(obj: dict) -> str: filter_type = obj["attributeDisplayForm"]["meta"]["category"].lower() - filter_type = "label" if filter_type == "attributedisplayform" else filter_type + if filter_type == "attributedisplayform": + return cloud_display_form_type( + obj["attributeDisplayForm"]["meta"]["identifier"] + ) return filter_type @staticmethod def _transform_attribute_type_value(obj: dict) -> str: attr_type = obj["attribute"]["meta"]["category"].lower() - return "label" if attr_type == "attribute" else attr_type + if attr_type == "attribute": + return cloud_display_form_type(obj["attribute"]["meta"]["identifier"]) + return attr_type def _get_filter_attribute_elements( self, attribute_elements: list diff --git a/src/gooddata_legacy2cloud/helpers.py b/src/gooddata_legacy2cloud/helpers.py index 6c668e7..aa1c0a2 100644 --- a/src/gooddata_legacy2cloud/helpers.py +++ b/src/gooddata_legacy2cloud/helpers.py @@ -170,6 +170,24 @@ def resolve_kpi_description(legacy_client: LegacyClient, obj: dict[str, Any]) -> return metric_obj["metric"]["meta"].get("summary", "") +COMPUTED_ATTRIBUTE_PREFIXES = ("attr.comp.", "label.comp.") + + +def is_computed_attribute(legacy_identifier: str) -> bool: + """Legacy computed attributes always use the attr.comp./label.comp. prefix.""" + return legacy_identifier.startswith(COMPUTED_ATTRIBUTE_PREFIXES) + + +def computed_attribute_cloud_id(legacy_identifier: str) -> str: + """Cloud computed attribute id is the Legacy label id (label.comp.X).""" + return "label." + legacy_identifier.split(".", 1)[1] + + +def cloud_display_form_type(legacy_identifier: str) -> str: + """Entity type Cloud expects for a Legacy display form reference.""" + return "computedAttribute" if is_computed_attribute(legacy_identifier) else "label" + + def get_cloud_id(title: str, legacy_identifier: str) -> str: """ Returns the Cloud metric identifier. diff --git a/src/gooddata_legacy2cloud/id_mappings.py b/src/gooddata_legacy2cloud/id_mappings.py index aacaa06..184dc1f 100644 --- a/src/gooddata_legacy2cloud/id_mappings.py +++ b/src/gooddata_legacy2cloud/id_mappings.py @@ -8,6 +8,11 @@ import logging import os +from gooddata_legacy2cloud.helpers import ( + computed_attribute_cloud_id, + is_computed_attribute, +) + logger = logging.getLogger("migration") @@ -71,6 +76,9 @@ def search_mapping_identifier(self, legacy_identifier): if legacy_identifier in self.id_mapping: return self.id_mapping[legacy_identifier] + if is_computed_attribute(legacy_identifier): + return computed_attribute_cloud_id(legacy_identifier) + raise ValueError( f"Search Cloud Id - Unknown Legacy identifier {legacy_identifier}" ) diff --git a/src/gooddata_legacy2cloud/insights/cloud_insight.py b/src/gooddata_legacy2cloud/insights/cloud_insight.py index 654fa04..e884144 100644 --- a/src/gooddata_legacy2cloud/insights/cloud_insight.py +++ b/src/gooddata_legacy2cloud/insights/cloud_insight.py @@ -8,7 +8,11 @@ import logging import uuid -from gooddata_legacy2cloud.helpers import get_cloud_id, parse_legacy_tags +from gooddata_legacy2cloud.helpers import ( + cloud_display_form_type, + get_cloud_id, + parse_legacy_tags, +) from gooddata_legacy2cloud.id_mappings import IdMappings from gooddata_legacy2cloud.insights.data_classes import InsightContext from gooddata_legacy2cloud.insights.period_comparison_insight import ( @@ -141,7 +145,10 @@ def _transform_identifier_in_date_filter(self, filter: dict, filter_name: str): @staticmethod def _transform_filter_type_value(obj: dict) -> str: filter_type = obj["attributeDisplayForm"]["meta"]["category"].lower() - filter_type = "label" if filter_type == "attributedisplayform" else filter_type + if filter_type == "attributedisplayform": + return cloud_display_form_type( + obj["attributeDisplayForm"]["meta"]["identifier"] + ) return filter_type def _process_cyclical_date_filter( diff --git a/src/gooddata_legacy2cloud/metrics/computed_attribute.py b/src/gooddata_legacy2cloud/metrics/computed_attribute.py new file mode 100644 index 0000000..122d1ca --- /dev/null +++ b/src/gooddata_legacy2cloud/metrics/computed_attribute.py @@ -0,0 +1,119 @@ +# (C) 2026 GoodData Corporation +""" +Transforms Legacy computed attributes (attr.comp.X + label.comp.X) into Cloud +computed attribute entities defined by a MAQL CASE expression. +""" + +import logging +import re + +from gooddata_legacy2cloud.backends.legacy.client import LegacyClient +from gooddata_legacy2cloud.helpers import ( + computed_attribute_cloud_id, + is_computed_attribute, + parse_legacy_tags, +) +from gooddata_legacy2cloud.metrics.data_classes import MetricContext + +logger = logging.getLogger("migration") + +_WHEN = ( + r"when \{(?P[^}]+)\} (?P<=|<|>=|>|=) (?P-?\d+(?:\.\d+)?) " + r'then \{[^?}]+\?"(?P