This is an automated email from the ASF dual-hosted git repository.
vavila 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 86e7139245 fix: Dataset currency (#33682)
86e7139245 is described below
commit 86e7139245e7444e749b5e8ad1d5167b4df53ce9
Author: Vitor Avila <[email protected]>
AuthorDate: Mon Jun 9 22:47:14 2025 -0300
fix: Dataset currency (#33682)
---
.../superset-ui-chart-controls/src/types.ts | 1 +
.../src/components/Datasource/DatasourceModal.tsx | 5 +-
.../src/explore/actions/hydrateExplore.test.ts | 48 ++++++++++++
.../src/explore/actions/hydrateExplore.ts | 9 +++
.../src/hooks/apiResources/dashboards.test.ts | 89 ++++++++++++++++++++++
.../src/hooks/apiResources/dashboards.ts | 17 ++++-
superset/datasets/schemas.py | 7 +-
...2_convert_metric_currencies_from_str_to_json.py | 84 ++++++++++++++++++++
tests/integration_tests/datasets/api_tests.py | 23 ++++++
9 files changed, 277 insertions(+), 6 deletions(-)
diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/types.ts
b/superset-frontend/packages/superset-ui-chart-controls/src/types.ts
index 71517884da..418efe55fc 100644
--- a/superset-frontend/packages/superset-ui-chart-controls/src/types.ts
+++ b/superset-frontend/packages/superset-ui-chart-controls/src/types.ts
@@ -69,6 +69,7 @@ export interface Dataset {
columns: ColumnMeta[];
metrics: Metric[];
column_formats: Record<string, string>;
+ currency_formats?: Record<string, Currency>;
verbose_map: Record<string, string>;
main_dttm_col: string;
// eg. ['["ds", true]', 'ds [asc]']
diff --git a/superset-frontend/src/components/Datasource/DatasourceModal.tsx
b/superset-frontend/src/components/Datasource/DatasourceModal.tsx
index c89f588a0c..e5c3e5c6cd 100644
--- a/superset-frontend/src/components/Datasource/DatasourceModal.tsx
+++ b/superset-frontend/src/components/Datasource/DatasourceModal.tsx
@@ -21,7 +21,6 @@ import { useSelector } from 'react-redux';
import Alert from 'src/components/Alert';
import Button from 'src/components/Button';
import {
- isDefined,
styled,
SupersetClient,
getClientErrorObject,
@@ -141,9 +140,7 @@ const DatasourceModal:
FunctionComponent<DatasourceModalProps> = ({
metric_name: metric.metric_name,
metric_type: metric.metric_type,
d3format: metric.d3format || null,
- currency: !isDefined(metric.currency)
- ? null
- : JSON.stringify(metric.currency),
+ currency: metric.currency,
verbose_name: metric.verbose_name,
warning_text: metric.warning_text,
uuid: metric.uuid,
diff --git a/superset-frontend/src/explore/actions/hydrateExplore.test.ts
b/superset-frontend/src/explore/actions/hydrateExplore.test.ts
index cdbca5b94d..f070369914 100644
--- a/superset-frontend/src/explore/actions/hydrateExplore.test.ts
+++ b/superset-frontend/src/explore/actions/hydrateExplore.test.ts
@@ -213,3 +213,51 @@ test('uses configured default time range if not set', ()
=> {
}),
);
});
+
+test('extracts currency formats from metrics in dataset', () => {
+ const dispatch = jest.fn();
+ const getState = jest.fn(() => ({
+ user: {},
+ charts: {},
+ datasources: {},
+ common: {},
+ explore: {},
+ }));
+
+ const datasetWithMetrics = {
+ ...exploreInitialData.dataset,
+ metrics: [
+ {
+ metric_name: 'count',
+ currency: { symbol: 'GBP', symbolPosition: 'prefix' },
+ },
+ {
+ metric_name: 'revenue',
+ currency: { symbol: 'USD', symbolPosition: 'suffix' },
+ },
+ { metric_name: 'no_currency' },
+ ],
+ };
+
+ // @ts-ignore
+ hydrateExplore({ ...exploreInitialData, dataset: datasetWithMetrics })(
+ dispatch,
+ // @ts-ignore
+ getState,
+ );
+
+ expect(dispatch).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({
+ datasources: expect.objectContaining({
+ '8__table': expect.objectContaining({
+ currency_formats: {
+ count: { symbol: 'GBP', symbolPosition: 'prefix' },
+ revenue: { symbol: 'USD', symbolPosition: 'suffix' },
+ },
+ }),
+ }),
+ }),
+ }),
+ );
+});
diff --git a/superset-frontend/src/explore/actions/hydrateExplore.ts
b/superset-frontend/src/explore/actions/hydrateExplore.ts
index 2ee46c1e93..b8e0375a64 100644
--- a/superset-frontend/src/explore/actions/hydrateExplore.ts
+++ b/superset-frontend/src/explore/actions/hydrateExplore.ts
@@ -27,6 +27,7 @@ import { getChartKey } from 'src/explore/exploreUtils';
import { getControlsState } from 'src/explore/store';
import { Dispatch } from 'redux';
import {
+ Currency,
ensureIsArray,
getCategoricalSchemeRegistry,
getColumnLabel,
@@ -97,6 +98,14 @@ export const hydrateExplore =
}
const initialDatasource = dataset;
+ initialDatasource.currency_formats = Object.fromEntries(
+ (initialDatasource.metrics ?? [])
+ .filter(metric => !!metric.currency)
+ .map((metric): [string, Currency] => [
+ metric.metric_name,
+ metric.currency!,
+ ]),
+ );
const initialExploreState = {
form_data: initialFormData,
diff --git a/superset-frontend/src/hooks/apiResources/dashboards.test.ts
b/superset-frontend/src/hooks/apiResources/dashboards.test.ts
new file mode 100644
index 0000000000..3a6265163e
--- /dev/null
+++ b/superset-frontend/src/hooks/apiResources/dashboards.test.ts
@@ -0,0 +1,89 @@
+/**
+ * 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.
+ */
+import { renderHook } from '@testing-library/react-hooks';
+import fetchMock from 'fetch-mock';
+import { useDashboardDatasets } from './dashboards';
+
+describe('useDashboardDatasets', () => {
+ const mockDatasets = [
+ {
+ id: 1,
+ metrics: [
+ {
+ metric_name: 'count',
+ currency: { symbol: 'GBP', symbolPosition: 'prefix' },
+ },
+ {
+ metric_name: 'revenue',
+ currency: { symbol: 'USD', symbolPosition: 'suffix' },
+ },
+ { metric_name: 'no_currency' },
+ ],
+ },
+ {
+ id: 2,
+ metrics: [{ metric_name: 'no_currency' }],
+ },
+ {
+ id: 3,
+ metrics: [
+ {
+ metric_name: 'other_currency',
+ currency: { symbol: 'CNY', symbolPosition: 'suffix' },
+ },
+ ],
+ },
+ ];
+
+ beforeEach(() => {
+ fetchMock.reset();
+ });
+
+ it('adds currencyFormats to datasets', async () => {
+ fetchMock.get('glob:*/api/v1/dashboard/*/datasets', {
+ result: mockDatasets,
+ });
+
+ const { result, waitForNextUpdate } = renderHook(() =>
+ useDashboardDatasets(1),
+ );
+ await waitForNextUpdate();
+
+ const expectedContent = [
+ {
+ ...mockDatasets[0],
+ currencyFormats: {
+ count: { symbol: 'GBP', symbolPosition: 'prefix' },
+ revenue: { symbol: 'USD', symbolPosition: 'suffix' },
+ },
+ },
+ {
+ ...mockDatasets[1],
+ currencyFormats: {},
+ },
+ {
+ ...mockDatasets[2],
+ currencyFormats: {
+ other_currency: { symbol: 'CNY', symbolPosition: 'suffix' },
+ },
+ },
+ ];
+ expect(result.current.result).toEqual(expectedContent);
+ });
+});
diff --git a/superset-frontend/src/hooks/apiResources/dashboards.ts
b/superset-frontend/src/hooks/apiResources/dashboards.ts
index 61896ba130..65e4b1c0bd 100644
--- a/superset-frontend/src/hooks/apiResources/dashboards.ts
+++ b/superset-frontend/src/hooks/apiResources/dashboards.ts
@@ -19,6 +19,7 @@
import { Dashboard, Datasource, EmbeddedDashboard } from 'src/dashboard/types';
import { Chart } from 'src/types/Chart';
+import { Currency } from '@superset-ui/core';
import { useApiV1Resource, useTransformedResource } from './apiResources';
export const useDashboard = (idOrSlug: string | number) =>
@@ -43,7 +44,21 @@ export const useDashboardCharts = (idOrSlug: string |
number) =>
// important: this endpoint only returns the fields in the dataset
// that are necessary for rendering the given dashboard
export const useDashboardDatasets = (idOrSlug: string | number) =>
- useApiV1Resource<Datasource[]>(`/api/v1/dashboard/${idOrSlug}/datasets`);
+ useTransformedResource(
+ useApiV1Resource<Datasource[]>(`/api/v1/dashboard/${idOrSlug}/datasets`),
+ datasets =>
+ datasets.map(dataset => ({
+ ...dataset,
+ currencyFormats: Object.fromEntries(
+ (dataset.metrics ?? [])
+ .filter(metric => !!metric.currency)
+ .map((metric): [string, Currency] => [
+ metric.metric_name,
+ metric.currency!,
+ ]),
+ ),
+ })),
+ );
export const useEmbeddedDashboard = (idOrSlug: string | number) =>
useApiV1Resource<EmbeddedDashboard>(`/api/v1/dashboard/${idOrSlug}/embedded`);
diff --git a/superset/datasets/schemas.py b/superset/datasets/schemas.py
index 54ce448173..405d0bab3d 100644
--- a/superset/datasets/schemas.py
+++ b/superset/datasets/schemas.py
@@ -74,6 +74,11 @@ class DatasetColumnsPutSchema(Schema):
uuid = fields.UUID(allow_none=True)
+class DatasetMetricCurrencyPutSchema(Schema):
+ symbol = fields.String(validate=Length(1, 128))
+ symbolPosition = fields.String(validate=Length(1, 128)) # noqa: N815
+
+
class DatasetMetricsPutSchema(Schema):
id = fields.Integer()
expression = fields.String(required=True)
@@ -82,7 +87,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.String(allow_none=True, required=False,
validate=Length(1, 128))
+ currency = fields.Nested(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)
diff --git
a/superset/migrations/versions/2025-06-06_00-39_363a9b1e8992_convert_metric_currencies_from_str_to_json.py
b/superset/migrations/versions/2025-06-06_00-39_363a9b1e8992_convert_metric_currencies_from_str_to_json.py
new file mode 100644
index 0000000000..708e7c52da
--- /dev/null
+++
b/superset/migrations/versions/2025-06-06_00-39_363a9b1e8992_convert_metric_currencies_from_str_to_json.py
@@ -0,0 +1,84 @@
+# 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.
+"""convert_metric_currencies_from_str_to_json
+
+Revision ID: 363a9b1e8992
+Revises: f1edd4a4d4f2
+Create Date: 2025-06-06 00:39:00.107746
+
+"""
+
+import json
+import logging
+
+from alembic import op
+from sqlalchemy import Column, Integer, JSON, String
+from sqlalchemy.ext.declarative import declarative_base
+
+from superset import db
+from superset.migrations.shared.utils import paginated_update
+
+logger = logging.getLogger("alembic")
+logger.setLevel(logging.INFO)
+
+# revision identifiers, used by Alembic.
+revision = "363a9b1e8992"
+down_revision = "f1edd4a4d4f2"
+
+Base = declarative_base()
+
+
+class SqlMetric(Base):
+ __tablename__ = "sql_metrics"
+
+ id = Column(Integer, primary_key=True)
+ metric_name = Column(String(512))
+ currency = Column(JSON)
+
+
+def upgrade():
+ bind = op.get_bind()
+ session = db.Session(bind=bind)
+ currency_configs =
session.query(SqlMetric).filter(SqlMetric.currency.isnot(None))
+ for metric in paginated_update(
+ currency_configs,
+ lambda current, total: logger.info((f"Upgrading {current}/{total}
metrics")),
+ ):
+ while True:
+ if isinstance(metric.currency, str):
+ try:
+ metric.currency = json.loads(metric.currency)
+ except Exception as e:
+ logger.error(
+ f"Error loading metric {metric.metric_name} as json:
{e}"
+ )
+ metric.currency = {}
+ break
+ else:
+ break
+
+
+def downgrade():
+ """
+ No op downgrade.
+
+ The downgrade could just do `metric.currency =
json.dumps(metric.currency)`. However
+ this is happening after `f1edd4a4d4f2` which already converted the
currency column
+ to JSON at the DB level, so we shouldn't have currencies in str anymore.
It was the
+ case because the client was still stringifying it.
+ """
+ pass
diff --git a/tests/integration_tests/datasets/api_tests.py
b/tests/integration_tests/datasets/api_tests.py
index ab645d300e..9d226f793d 100644
--- a/tests/integration_tests/datasets/api_tests.py
+++ b/tests/integration_tests/datasets/api_tests.py
@@ -1462,6 +1462,29 @@ class TestDatasetApi(SupersetTestCase):
assert data == expected_result
self.items_to_delete = [dataset]
+ def test_update_dataset_update_metric_invalid_currency(self):
+ """
+ Dataset API: Test update dataset metric with an invalid currency config
+ """
+
+ dataset = self.insert_default_dataset()
+
+ self.login(ADMIN_USERNAME)
+ uri = f"api/v1/dataset/{dataset.id}"
+ data = {
+ "metrics": [
+ {
+ "metric_name": "test",
+ "expression": "COUNT(*)",
+ "currency": '{"symbol": "USD", "symbolPosition":
"suffix"}',
+ },
+ ]
+ }
+ rv = self.put_assert_metric(uri, data, "put")
+ assert rv.status_code == 422
+
+ self.items_to_delete = [dataset]
+
def test_update_dataset_item_gamma(self):
"""
Dataset API: Test update dataset item gamma