This is an automated email from the ASF dual-hosted git repository.
richardfogaca pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new 5278deaf635 fix(metrics): normalize legacy currency strings (#37455)
5278deaf635 is described below
commit 5278deaf63536fd07f3c1007d2d393f9f076c692
Author: Richard Fogaca Nienkotter
<[email protected]>
AuthorDate: Thu Feb 19 21:25:44 2026 -0300
fix(metrics): normalize legacy currency strings (#37455)
---
.../CurrencyControl/CurrencyControl.test.tsx | 29 ++++-
.../controls/CurrencyControl/CurrencyControl.tsx | 39 +++++--
superset/connectors/sqla/models.py | 3 +-
superset/datasets/schemas.py | 22 +++-
superset/models/sql_types/__init__.py | 7 ++
superset/models/sql_types/base.py | 106 +++++++++++++++++
tests/integration_tests/datasets/api_tests.py | 2 +-
tests/unit_tests/datasets/schema_tests.py | 56 +++++++++
.../unit_tests}/models/sql_types/__init__.py | 0
.../models/sql_types/currency_type_test.py | 129 +++++++++++++++++++++
10 files changed, 377 insertions(+), 16 deletions(-)
diff --git
a/superset-frontend/src/explore/components/controls/CurrencyControl/CurrencyControl.test.tsx
b/superset-frontend/src/explore/components/controls/CurrencyControl/CurrencyControl.test.tsx
index 39a1a64ee29..a40f5750446 100644
---
a/superset-frontend/src/explore/components/controls/CurrencyControl/CurrencyControl.test.tsx
+++
b/superset-frontend/src/explore/components/controls/CurrencyControl/CurrencyControl.test.tsx
@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { render } from 'spec/helpers/testing-library';
+import { render, selectOption } from 'spec/helpers/testing-library';
import { CurrencyControl } from './CurrencyControl';
test('CurrencyControl renders position and symbol selects', () => {
@@ -36,3 +36,30 @@ test('CurrencyControl renders position and symbol selects',
() => {
).toBeInTheDocument();
expect(container.querySelectorAll('.ant-select')).toHaveLength(2);
});
+
+test('CurrencyControl handles string currency value', async () => {
+ const onChange = jest.fn();
+ const { container } = render(
+ <CurrencyControl
+ onChange={onChange}
+ value='{"symbol":"USD","symbolPosition":"prefix"}'
+ />,
+ {
+ useRedux: true,
+ initialState: {
+ common: { currencies: ['USD', 'EUR'] },
+ explore: { datasource: {} },
+ },
+ },
+ );
+
+ expect(
+ container.querySelector('[data-test="currency-control-container"]'),
+ ).toBeInTheDocument();
+
+ await selectOption('Suffix', 'Currency prefix or suffix');
+ expect(onChange).toHaveBeenLastCalledWith({
+ symbol: 'USD',
+ symbolPosition: 'suffix',
+ });
+});
diff --git
a/superset-frontend/src/explore/components/controls/CurrencyControl/CurrencyControl.tsx
b/superset-frontend/src/explore/components/controls/CurrencyControl/CurrencyControl.tsx
index 8ec68904440..3da754330fd 100644
---
a/superset-frontend/src/explore/components/controls/CurrencyControl/CurrencyControl.tsx
+++
b/superset-frontend/src/explore/components/controls/CurrencyControl/CurrencyControl.tsx
@@ -29,7 +29,7 @@ import ControlHeader from '../../ControlHeader';
export interface CurrencyControlProps {
onChange: (currency: Partial<Currency>) => void;
- value?: Partial<Currency>;
+ value?: Partial<Currency> | string | null;
symbolSelectOverrideProps?: Partial<SelectProps>;
currencySelectOverrideProps?: Partial<SelectProps>;
symbolSelectAdditionalStyles?: CSSObject;
@@ -59,9 +59,12 @@ export const CURRENCY_SYMBOL_POSITION_OPTIONS = [
{ value: 'suffix', label: t('Suffix') },
];
+const isCurrencyObject = (value: unknown): value is Partial<Currency> =>
+ !!value && typeof value === 'object' && !Array.isArray(value);
+
export const CurrencyControl = ({
onChange,
- value: currency = {},
+ value: rawCurrency = {},
symbolSelectOverrideProps = {},
currencySelectOverrideProps = {},
symbolSelectAdditionalStyles,
@@ -69,6 +72,24 @@ export const CurrencyControl = ({
...props
}: CurrencyControlProps) => {
const theme = useTheme();
+ const normalizedCurrency = useMemo<Partial<Currency>>(() => {
+ if (isCurrencyObject(rawCurrency)) {
+ return rawCurrency;
+ }
+
+ if (typeof rawCurrency === 'string') {
+ try {
+ const parsed = JSON.parse(rawCurrency) as unknown;
+ if (isCurrencyObject(parsed)) {
+ return parsed;
+ }
+ } catch {
+ return {};
+ }
+ }
+
+ return {};
+ }, [rawCurrency]);
const currencies = useSelector<ViewState, string[]>(
state => state.common?.currencies,
);
@@ -155,10 +176,12 @@ export const CurrencyControl = ({
options={CURRENCY_SYMBOL_POSITION_OPTIONS}
placeholder={t('Prefix or suffix')}
onChange={(symbolPosition: string) => {
- onChange({ ...currency, symbolPosition });
+ onChange({ ...normalizedCurrency, symbolPosition });
}}
- onClear={() => onChange({ ...currency, symbolPosition: undefined })}
- value={currency?.symbolPosition}
+ onClear={() =>
+ onChange({ ...normalizedCurrency, symbolPosition: undefined })
+ }
+ value={normalizedCurrency?.symbolPosition}
allowClear
{...symbolSelectOverrideProps}
/>
@@ -167,10 +190,10 @@ export const CurrencyControl = ({
options={currenciesOptions}
placeholder={t('Currency')}
onChange={(symbol: string) => {
- onChange({ ...currency, symbol });
+ onChange({ ...normalizedCurrency, symbol });
}}
- onClear={() => onChange({ ...currency, symbol: undefined })}
- value={currency?.symbol}
+ onClear={() => onChange({ ...normalizedCurrency, symbol: undefined
})}
+ value={normalizedCurrency?.symbol}
allowClear
allowNewOptions
sortComparator={currencySortComparator}
diff --git a/superset/connectors/sqla/models.py
b/superset/connectors/sqla/models.py
index be74a199672..ddd31e78fa7 100644
--- a/superset/connectors/sqla/models.py
+++ b/superset/connectors/sqla/models.py
@@ -103,6 +103,7 @@ from superset.models.helpers import (
SQLA_QUERY_KEYS,
)
from superset.models.slice import Slice
+from superset.models.sql_types.base import CurrencyType
from superset.sql.parse import Table
from superset.superset_typing import (
AdhocColumn,
@@ -1093,7 +1094,7 @@ class SqlMetric(AuditMixinNullable, ImportExportMixin,
CertificationMixin, Model
metric_type = Column(String(32))
description = Column(utils.MediumText())
d3format = Column(String(128))
- currency = Column(JSON, nullable=True)
+ currency = Column(CurrencyType, nullable=True)
warning_text = Column(Text)
table_id = Column(Integer, ForeignKey("tables.id", ondelete="CASCADE"))
expression = Column(utils.MediumText(), nullable=False)
diff --git a/superset/datasets/schemas.py b/superset/datasets/schemas.py
index 82f1e7faa1c..1a30a6bd799 100644
--- a/superset/datasets/schemas.py
+++ b/superset/datasets/schemas.py
@@ -32,6 +32,7 @@ from marshmallow.validate import Length, OneOf
from superset import security_manager
from superset.connectors.sqla.models import SqlaTable
from superset.exceptions import SupersetMarshmallowValidationError
+from superset.models.sql_types import parse_currency_string
from superset.utils import json
get_delete_ids_schema = {"type": "array", "items": {"type": "integer"}}
@@ -97,6 +98,20 @@ class DatasetMetricCurrencyPutSchema(Schema):
symbolPosition = fields.String(validate=Length(1, 128)) # noqa: N815
+class CurrencyField(fields.Nested):
+ """
+ Nested field that tolerates legacy string payloads for currency.
+ """
+
+ def _deserialize(
+ self, value: Any, attr: str | None, data: dict[str, Any], **kwargs: Any
+ ) -> Any:
+ if isinstance(value, str):
+ value = parse_currency_string(value)
+
+ return super()._deserialize(value, attr, data, **kwargs)
+
+
class DatasetMetricsPutSchema(Schema):
id = fields.Integer()
expression = fields.String(required=True)
@@ -105,7 +120,7 @@ class DatasetMetricsPutSchema(Schema):
metric_name = fields.String(required=True, validate=Length(1, 255))
metric_type = fields.String(allow_none=True, validate=Length(1, 32))
d3format = fields.String(allow_none=True, validate=Length(1, 128))
- currency = fields.Nested(DatasetMetricCurrencyPutSchema, allow_none=True)
+ currency = CurrencyField(DatasetMetricCurrencyPutSchema, allow_none=True)
verbose_name = fields.String(allow_none=True, metadata={Length: (1, 1024)})
warning_text = fields.String(allow_none=True)
uuid = fields.UUID(allow_none=True)
@@ -276,9 +291,6 @@ class ImportV1MetricSchema(Schema):
if isinstance(data.get("extra"), str):
data["extra"] = json.loads(data["extra"])
- if isinstance(data.get("currency"), str):
- data["currency"] = json.loads(data["currency"])
-
return data
metric_name = fields.String(required=True)
@@ -287,7 +299,7 @@ class ImportV1MetricSchema(Schema):
expression = fields.String(required=True)
description = fields.String(allow_none=True)
d3format = fields.String(allow_none=True)
- currency = fields.Nested(ImportMetricCurrencySchema, allow_none=True)
+ currency = CurrencyField(ImportMetricCurrencySchema, allow_none=True)
extra = fields.Dict(allow_none=True)
warning_text = fields.String(allow_none=True)
diff --git a/superset/models/sql_types/__init__.py
b/superset/models/sql_types/__init__.py
index 13a83393a91..05407b9f442 100644
--- a/superset/models/sql_types/__init__.py
+++ b/superset/models/sql_types/__init__.py
@@ -14,3 +14,10 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
+
+from superset.models.sql_types.base import CurrencyType, parse_currency_string
+
+__all__ = [
+ "CurrencyType",
+ "parse_currency_string",
+]
diff --git a/superset/models/sql_types/base.py
b/superset/models/sql_types/base.py
new file mode 100644
index 00000000000..355d9df5115
--- /dev/null
+++ b/superset/models/sql_types/base.py
@@ -0,0 +1,106 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Base SQL types for Superset models."""
+
+from __future__ import annotations
+
+# pylint: disable=abstract-method
+import ast
+from typing import Any
+
+from sqlalchemy.engine.interfaces import Dialect
+from sqlalchemy.types import JSON, TypeDecorator
+
+from superset.utils import json
+
+
+def _try_parse_currency(value: str) -> Any | None:
+ try:
+ return json.loads(value)
+ except (json.JSONDecodeError, TypeError):
+ pass
+
+ try:
+ return ast.literal_eval(value)
+ except (ValueError, SyntaxError):
+ return None
+
+
+def parse_currency_string(value: str) -> dict[str, Any]:
+ if not value:
+ return {}
+
+ current: Any = value
+ for _ in range(2):
+ if isinstance(current, dict):
+ return current
+ if not isinstance(current, str):
+ return {}
+
+ parsed = _try_parse_currency(current)
+ if parsed is None:
+ return {}
+ if isinstance(parsed, str):
+ current = parsed
+ continue
+ if isinstance(parsed, dict):
+ return parsed
+ return {}
+
+ return {}
+
+
+class CurrencyType(TypeDecorator):
+ """
+ Custom SQLAlchemy type for metric currency that ensures string values
+ are parsed to dicts when read from the database.
+
+ This handles legacy data that was stored as stringified JSON before
+ the currency column was migrated from VARCHAR to JSON type. Some data
+ may have been double-encoded or stored with Python dict formatting.
+
+ Example problematic values this handles:
+ - '{"symbol": "USD", "symbolPosition": "prefix"}' (JSON string)
+ - "{'symbol': 'EUR', 'symbolPosition': 'suffix'}" (Python dict string)
+ """
+
+ impl = JSON
+ cache_ok = True
+
+ def process_result_value(
+ self, value: Any, dialect: Dialect
+ ) -> dict[str, Any] | None:
+ """
+ Process value when reading from database.
+
+ Ensures the returned value is always a dict (or None), even if
+ the stored value is a string representation of a dict.
+
+ Args:
+ value: The value from the database (could be None, dict, or string)
+ dialect: The SQLAlchemy dialect being used
+
+ Returns:
+ A dict representing the currency configuration, or None
+ """
+ if value is None:
+ return None
+
+ if isinstance(value, str):
+ return parse_currency_string(value)
+
+ return value
diff --git a/tests/integration_tests/datasets/api_tests.py
b/tests/integration_tests/datasets/api_tests.py
index 6677f19f8ad..c0692fe9741 100644
--- a/tests/integration_tests/datasets/api_tests.py
+++ b/tests/integration_tests/datasets/api_tests.py
@@ -1510,7 +1510,7 @@ class TestDatasetApi(SupersetTestCase):
{
"metric_name": "test",
"expression": "COUNT(*)",
- "currency": '{"symbol": "USD", "symbolPosition":
"suffix"}',
+ "currency": {"symbol": "", "symbolPosition": ""},
},
]
}
diff --git a/tests/unit_tests/datasets/schema_tests.py
b/tests/unit_tests/datasets/schema_tests.py
index 280a2440c2d..40d63f3af8b 100644
--- a/tests/unit_tests/datasets/schema_tests.py
+++ b/tests/unit_tests/datasets/schema_tests.py
@@ -75,3 +75,59 @@ def test_dataset_put_schema_currency_code_column_optional()
-> None:
"currency_code_column" not in result
or result.get("currency_code_column") is None
)
+
+
+def test_dataset_metrics_put_schema_parses_currency_string() -> None:
+ """Test that DatasetMetricsPutSchema parses string currency payloads."""
+ from superset.datasets.schemas import DatasetMetricsPutSchema
+
+ schema = DatasetMetricsPutSchema()
+ data = {
+ "expression": "SUM(amount)",
+ "metric_name": "sum_amount",
+ "currency": '{"symbol": "EUR", "symbolPosition": "suffix"}',
+ }
+ result = schema.load(data)
+ assert result["currency"] == {"symbol": "EUR", "symbolPosition": "suffix"}
+
+
+def test_dataset_metrics_put_schema_parses_python_dict_string() -> None:
+ """Test that DatasetMetricsPutSchema parses Python dict currency
strings."""
+ from superset.datasets.schemas import DatasetMetricsPutSchema
+
+ schema = DatasetMetricsPutSchema()
+ data = {
+ "expression": "SUM(amount)",
+ "metric_name": "sum_amount",
+ "currency": "{'symbol': 'GBP', 'symbolPosition': 'prefix'}",
+ }
+ result = schema.load(data)
+ assert result["currency"] == {"symbol": "GBP", "symbolPosition": "prefix"}
+
+
+def test_dataset_metrics_put_schema_handles_malformed_currency() -> None:
+ """Test that DatasetMetricsPutSchema normalizes malformed currency
strings."""
+ from superset.datasets.schemas import DatasetMetricsPutSchema
+
+ schema = DatasetMetricsPutSchema()
+ data = {
+ "expression": "SUM(amount)",
+ "metric_name": "sum_amount",
+ "currency": "not valid json",
+ }
+ result = schema.load(data)
+ assert result["currency"] == {}
+
+
+def test_import_v1_metric_schema_parses_currency_string() -> None:
+ """Test that ImportV1MetricSchema parses string currency payloads."""
+ from superset.datasets.schemas import ImportV1MetricSchema
+
+ schema = ImportV1MetricSchema()
+ data = {
+ "metric_name": "sum_amount",
+ "expression": "SUM(amount)",
+ "currency": '{"symbol": "CAD", "symbolPosition": "suffix"}',
+ }
+ result = schema.load(data)
+ assert result["currency"] == {"symbol": "CAD", "symbolPosition": "suffix"}
diff --git a/superset/models/sql_types/__init__.py
b/tests/unit_tests/models/sql_types/__init__.py
similarity index 100%
copy from superset/models/sql_types/__init__.py
copy to tests/unit_tests/models/sql_types/__init__.py
diff --git a/tests/unit_tests/models/sql_types/currency_type_test.py
b/tests/unit_tests/models/sql_types/currency_type_test.py
new file mode 100644
index 00000000000..58f94d3b850
--- /dev/null
+++ b/tests/unit_tests/models/sql_types/currency_type_test.py
@@ -0,0 +1,129 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Tests for CurrencyType - a custom SQLAlchemy type for metric currency."""
+
+from unittest.mock import MagicMock
+
+import pytest
+
+from superset.models.sql_types.base import CurrencyType
+
+
[email protected]
+def currency_type() -> CurrencyType:
+ """Create a CurrencyType instance for testing."""
+ return CurrencyType()
+
+
[email protected]
+def mock_dialect() -> MagicMock:
+ """Create a mock SQLAlchemy dialect."""
+ return MagicMock()
+
+
+def test_process_result_value_with_none(
+ currency_type: CurrencyType, mock_dialect: MagicMock
+) -> None:
+ """Test that None values are returned as-is."""
+ result = currency_type.process_result_value(None, mock_dialect)
+ assert result is None
+
+
+def test_process_result_value_with_dict(
+ currency_type: CurrencyType, mock_dialect: MagicMock
+) -> None:
+ """Test that dict values are returned as-is."""
+ value = {"symbol": "USD", "symbolPosition": "prefix"}
+ result = currency_type.process_result_value(value, mock_dialect)
+ assert result == {"symbol": "USD", "symbolPosition": "prefix"}
+
+
+def test_process_result_value_with_empty_dict(
+ currency_type: CurrencyType, mock_dialect: MagicMock
+) -> None:
+ """Test that empty dict values are returned as-is."""
+ result = currency_type.process_result_value({}, mock_dialect)
+ assert result == {}
+
+
+def test_process_result_value_with_json_string(
+ currency_type: CurrencyType, mock_dialect: MagicMock
+) -> None:
+ """Test that JSON string values are parsed to dict."""
+ value = '{"symbol": "EUR", "symbolPosition": "suffix"}'
+ result = currency_type.process_result_value(value, mock_dialect)
+ assert result == {"symbol": "EUR", "symbolPosition": "suffix"}
+
+
+def test_process_result_value_with_python_dict_string(
+ currency_type: CurrencyType, mock_dialect: MagicMock
+) -> None:
+ """Test that Python dict string (single quotes) values are parsed to
dict."""
+ value = "{'symbol': 'GBP', 'symbolPosition': 'prefix'}"
+ result = currency_type.process_result_value(value, mock_dialect)
+ assert result == {"symbol": "GBP", "symbolPosition": "prefix"}
+
+
+def test_process_result_value_with_double_encoded_json_string(
+ currency_type: CurrencyType, mock_dialect: MagicMock
+) -> None:
+ """Test that double-encoded JSON strings are parsed to dict."""
+ value = '"{\\"symbol\\": \\"EUR\\", \\"symbolPosition\\": \\"suffix\\"}"'
+ result = currency_type.process_result_value(value, mock_dialect)
+ assert result == {"symbol": "EUR", "symbolPosition": "suffix"}
+
+
+def test_process_result_value_with_malformed_string(
+ currency_type: CurrencyType, mock_dialect: MagicMock
+) -> None:
+ """Test that malformed string values return empty dict."""
+ value = "not valid json at all"
+ result = currency_type.process_result_value(value, mock_dialect)
+ assert result == {}
+
+
+def test_process_result_value_with_empty_string(
+ currency_type: CurrencyType, mock_dialect: MagicMock
+) -> None:
+ """Test that empty string values return empty dict."""
+ result = currency_type.process_result_value("", mock_dialect)
+ assert result == {}
+
+
+def test_process_result_value_with_partial_json_string(
+ currency_type: CurrencyType, mock_dialect: MagicMock
+) -> None:
+ """Test that partial JSON string (only symbol) is parsed correctly."""
+ value = '{"symbol": "JPY"}'
+ result = currency_type.process_result_value(value, mock_dialect)
+ assert result == {"symbol": "JPY"}
+
+
+def test_cache_ok_is_true(currency_type: CurrencyType) -> None:
+ """Test that cache_ok is True for SQLAlchemy compatibility."""
+ assert currency_type.cache_ok is True
+
+
+def test_impl_is_json(currency_type: CurrencyType) -> None:
+ """Test that the underlying implementation is JSON type."""
+ from sqlalchemy.types import JSON
+
+ impl = currency_type.impl
+ if isinstance(impl, type):
+ assert issubclass(impl, JSON)
+ else:
+ assert isinstance(impl, JSON)