From f1fdc8a524d8a797f2575f057ff705bb42cd8579 Mon Sep 17 00:00:00 2001 From: Beto Dealmeida Date: Wed, 22 Apr 2026 14:34:51 -0400 Subject: [PATCH] Dynamic columns/metrics --- .../src/explore/actions/exploreActions.ts | 74 +++++++++++++++++++ .../DatasourcePanelDragOption/index.tsx | 42 ++++++++++- .../components/ExploreViewContainer/index.tsx | 44 +++++++++++ .../DndColumnMetricSelect.tsx | 14 +++- .../DndMetricSelect.tsx | 6 +- .../AdhocFilterEditPopover/index.tsx | 34 +++++---- .../src/explore/reducers/exploreReducer.ts | 20 +++++ superset/datasource/api.py | 43 ++++++++--- superset/semantic_layers/models.py | 3 + 9 files changed, 250 insertions(+), 30 deletions(-) diff --git a/superset-frontend/src/explore/actions/exploreActions.ts b/superset-frontend/src/explore/actions/exploreActions.ts index cfcbd8a524e..59bab226c51 100644 --- a/superset-frontend/src/explore/actions/exploreActions.ts +++ b/superset-frontend/src/explore/actions/exploreActions.ts @@ -153,6 +153,79 @@ export function setForceQuery(force: boolean) { }; } +export const SET_COMPATIBILITY = 'SET_COMPATIBILITY'; +export function setCompatibility(payload: { + compatibleMetrics: string[] | null; + compatibleDimensions: string[] | null; + compatibilityLoading: boolean; +}) { + return { type: SET_COMPATIBILITY, ...payload }; +} + +/** + * Fetch compatible metrics and dimensions for the current selection. + * + * Only fires for semantic views — SQL datasets always have full compatibility + * so we short-circuit to `null` (no filtering) for everything else. + * + * Covers both real-time selection changes (M3) and saved-chart loading (M4): + * call this thunk on mount as well as whenever the metric / dimension + * selection changes in Explore. + */ +export function fetchCompatibility( + datasourceType: string, + datasourceId: number, + selectedMetrics: string[], + selectedDimensions: string[], +) { + return async (dispatch: Dispatch) => { + if (datasourceType !== 'semantic_view') { + dispatch( + setCompatibility({ + compatibleMetrics: null, + compatibleDimensions: null, + compatibilityLoading: false, + }), + ); + return; + } + + dispatch( + setCompatibility({ + compatibleMetrics: null, + compatibleDimensions: null, + compatibilityLoading: true, + }), + ); + + try { + const { json } = await SupersetClient.post({ + endpoint: `/api/v1/datasource/${datasourceType}/${datasourceId}/compatible`, + jsonPayload: { + selected_metrics: selectedMetrics, + selected_dimensions: selectedDimensions, + }, + }); + dispatch( + setCompatibility({ + compatibleMetrics: json.result.compatible_metrics, + compatibleDimensions: json.result.compatible_dimensions, + compatibilityLoading: false, + }), + ); + } catch { + // On error fall back to no filtering so the user is never blocked. + dispatch( + setCompatibility({ + compatibleMetrics: null, + compatibleDimensions: null, + compatibilityLoading: false, + }), + ); + } + }; +} + export const SET_STASH_FORM_DATA = 'SET_STASH_FORM_DATA'; export function setStashFormData( isHidden: boolean, @@ -195,6 +268,7 @@ export const exploreActions = { sliceUpdated, setForceQuery, syncDatasourceMetadata, + fetchCompatibility, }; export type ExploreActions = typeof exploreActions; diff --git a/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanelDragOption/index.tsx b/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanelDragOption/index.tsx index dad76da609d..441c6a5edbf 100644 --- a/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanelDragOption/index.tsx +++ b/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanelDragOption/index.tsx @@ -16,8 +16,9 @@ * specific language governing permissions and limitations * under the License. */ -import { RefObject } from 'react'; +import { RefObject, useMemo } from 'react'; import { useDrag } from 'react-dnd'; +import { useSelector } from 'react-redux'; import { Metric } from '@superset-ui/core'; import { css, styled, useTheme } from '@apache-superset/core/theme'; import { ColumnMeta } from '@superset-ui/chart-controls'; @@ -27,6 +28,7 @@ import { StyledMetricOption, } from 'src/explore/components/optionRenderers'; import { Icons } from '@superset-ui/core/components/Icons'; +import { ExplorePageState } from 'src/explore/types'; import { DatasourcePanelDndItem } from '../types'; @@ -70,11 +72,40 @@ export default function DatasourcePanelDragOption( ) { const { labelRef, showTooltip, type, value } = props; const theme = useTheme(); + + // Read compatibility lists from Redux. + // `null` means no filtering is active (SQL datasets, or no selection yet). + const compatibleMetrics = useSelector< + ExplorePageState, + string[] | null | undefined + >(state => state.explore.compatibleMetrics); + const compatibleDimensions = useSelector< + ExplorePageState, + string[] | null | undefined + >(state => state.explore.compatibleDimensions); + + // An item is compatible when the list is null (no filter) or when its + // name explicitly appears in the list returned by the backend. + const isCompatible = useMemo(() => { + if (type === DndItemType.Metric) { + if (!compatibleMetrics) return true; + return compatibleMetrics.includes((value as Metric).metric_name); + } + if (type === DndItemType.Column) { + if (!compatibleDimensions) return true; + return compatibleDimensions.includes( + (value as ColumnMeta).column_name, + ); + } + return true; + }, [type, value, compatibleMetrics, compatibleDimensions]); + const [{ isDragging }, drag] = useDrag({ item: { value: props.value, type: props.type, }, + canDrag: isCompatible, collect: monitor => ({ isDragging: monitor.isDragging(), }), @@ -87,7 +118,14 @@ export default function DatasourcePanelDragOption( }; return ( - + {type === DndItemType.Column ? ( ) : ( diff --git a/superset-frontend/src/explore/components/ExploreViewContainer/index.tsx b/superset-frontend/src/explore/components/ExploreViewContainer/index.tsx index 26110471a7f..0e20c784a71 100644 --- a/superset-frontend/src/explore/components/ExploreViewContainer/index.tsx +++ b/superset-frontend/src/explore/components/ExploreViewContainer/index.tsx @@ -36,6 +36,7 @@ import { JsonObject, MatrixifyFormData, DatasourceType, + ensureIsArray, } from '@superset-ui/core'; import { ControlStateMapping, @@ -412,6 +413,49 @@ function ExploreViewContainer(props: ExploreViewContainerProps) { [originalTitle, theme?.brandAppName, theme?.brandLogoAlt], ); + // M3 + M4: fire compatibility check on mount and whenever the metric / + // dimension selection changes. Only semantic views use the endpoint; + // SQL datasets short-circuit to null inside fetchCompatibility. + const selectedMetrics = useMemo( + () => + ensureIsArray(props.form_data.metrics).filter( + (m): m is string => typeof m === 'string', + ), + // eslint-disable-next-line react-hooks/exhaustive-deps + [JSON.stringify(props.form_data.metrics)], + ); + const selectedDimensions = useMemo( + () => + [ + ...ensureIsArray(props.form_data.groupby), + ...ensureIsArray(props.form_data.columns), + ...(typeof props.form_data.x_axis === 'string' + ? [props.form_data.x_axis] + : []), + ].filter((d): d is string => typeof d === 'string'), + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + JSON.stringify(props.form_data.groupby), + JSON.stringify(props.form_data.columns), + props.form_data.x_axis, + ], + ); + useEffect(() => { + props.actions.fetchCompatibility( + props.datasource.type, + props.datasource.id as number, + selectedMetrics, + selectedDimensions, + ); + // props.datasource.id covers the saved-chart-loading case (M4) + }, [ + props.datasource.id, + props.datasource.type, + selectedMetrics, + selectedDimensions, + ]); + + const addHistory = useCallback( async ({ isReplace = false, title } = {}) => { const formData = props.dashboardId diff --git a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndColumnMetricSelect.tsx b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndColumnMetricSelect.tsx index 1f81bbb9a85..5deda5f571a 100644 --- a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndColumnMetricSelect.tsx +++ b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndColumnMetricSelect.tsx @@ -130,6 +130,16 @@ function DndColumnMetricSelect(props: DndColumnMetricSelectProps) { formData, } = props; + // Semantic views do not support arbitrary SQL expressions as dimensions. + // Merge 'sqlExpression' into disabledTabs so the Custom SQL tab is hidden. + const effectiveDisabledTabs = useMemo( + () => + datasource?.type === 'semantic_view' + ? new Set([...(disabledTabs ?? []), 'sqlExpression']) + : disabledTabs, + [datasource?.type, disabledTabs], + ); + const [newColumnPopoverVisible, setNewColumnPopoverVisible] = useState(false); const combinedOptionsMap = useMemo(() => { @@ -304,7 +314,7 @@ function DndColumnMetricSelect(props: DndColumnMetricSelectProps) { }} editedColumn={column} isTemporal={isTemporal} - disabledTabs={disabledTabs} + disabledTabs={effectiveDisabledTabs} > diff --git a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndMetricSelect.tsx b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndMetricSelect.tsx index de5608c03e2..11de1e33757 100644 --- a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndMetricSelect.tsx +++ b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndMetricSelect.tsx @@ -132,6 +132,10 @@ const DndMetricSelect = (props: any) => { return extra; }, [datasource?.extra]); + // Semantic views do not support arbitrary SQL expressions as metrics. + const disallowAdhocMetrics = + extra.disallow_adhoc_metrics || datasource?.type === 'semantic_view'; + const savedMetricSet = useMemo( () => new Set( @@ -184,7 +188,7 @@ const DndMetricSelect = (props: any) => { const canDrop = useCallback( (item: DatasourcePanelDndItem) => { if ( - extra.disallow_adhoc_metrics && + disallowAdhocMetrics && (item.type !== DndItemType.Metric || !savedMetricSet.has(item.value.metric_name)) ) { diff --git a/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterEditPopover/index.tsx b/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterEditPopover/index.tsx index 97cb4bfb2f1..e8158462ea5 100644 --- a/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterEditPopover/index.tsx +++ b/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterEditPopover/index.tsx @@ -415,21 +415,25 @@ export default class AdhocFilterEditPopover extends Component< ), }, - { - key: ExpressionTypes.Sql, - label: t('Custom SQL'), - children: ( - - - - ), - }, + ...(datasource?.type === 'semantic_view' + ? [] + : [ + { + key: ExpressionTypes.Sql, + label: t('Custom SQL'), + children: ( + + + + ), + }, + ]), ]} /> {hasDeckSlices && ( diff --git a/superset-frontend/src/explore/reducers/exploreReducer.ts b/superset-frontend/src/explore/reducers/exploreReducer.ts index e940699e32c..94130d85efb 100644 --- a/superset-frontend/src/explore/reducers/exploreReducer.ts +++ b/superset-frontend/src/explore/reducers/exploreReducer.ts @@ -65,6 +65,9 @@ export interface ExploreState { metadata?: { owners?: string[] | null; }; + compatibleMetrics?: string[] | null; + compatibleDimensions?: string[] | null; + compatibilityLoading?: boolean; saveAction?: SaveActionType | null; } @@ -165,6 +168,13 @@ interface SetForceQueryAction { force: boolean; } +interface SetCompatibilityAction { + type: typeof actions.SET_COMPATIBILITY; + compatibleMetrics: string[] | null; + compatibleDimensions: string[] | null; + compatibilityLoading: boolean; +} + type ExploreAction = | DynamicPluginControlsReadyAction | ToggleFaveStarAction @@ -183,6 +193,7 @@ type ExploreAction = | SetStashFormDataAction | SliceUpdatedAction | SetForceQueryAction + | SetCompatibilityAction | HydrateExplore; // Extended control state for dynamic form controls - uses Record for flexibility @@ -621,6 +632,15 @@ export default function exploreReducer( force: typedAction.force, }; }, + [actions.SET_COMPATIBILITY]() { + const typedAction = action as SetCompatibilityAction; + return { + ...state, + compatibleMetrics: typedAction.compatibleMetrics, + compatibleDimensions: typedAction.compatibleDimensions, + compatibilityLoading: typedAction.compatibilityLoading, + }; + }, [HYDRATE_EXPLORE]() { const typedAction = action as HydrateExplore; return { diff --git a/superset/datasource/api.py b/superset/datasource/api.py index ec529d9a82a..0f66ddc6659 100644 --- a/superset/datasource/api.py +++ b/superset/datasource/api.py @@ -14,6 +14,7 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. +import hashlib import logging from typing import Any @@ -27,7 +28,9 @@ from superset.connectors.sqla.models import BaseDatasource from superset.daos.datasource import DatasourceDAO from superset.daos.exceptions import DatasourceNotFound, DatasourceTypeNotSupportedError from superset.exceptions import SupersetSecurityException +from superset.extensions import cache_manager from superset.superset_typing import FlaskResponse +from superset.utils import json from superset.utils.core import apply_max_row_limit, DatasourceType, SqlExpressionType from superset.views.base_api import BaseSupersetApi, statsd_metrics @@ -398,17 +401,37 @@ class DatasourceRestApi(BaseSupersetApi): selected_metrics = body.get("selected_metrics", []) selected_dimensions = body.get("selected_dimensions", []) - return self.response( - 200, - result={ - "compatible_metrics": datasource.get_compatible_metrics( - selected_metrics, selected_dimensions - ), - "compatible_dimensions": datasource.get_compatible_dimensions( - selected_metrics, selected_dimensions - ), - }, + # Build a stable cache key from the datasource identity and the + # (sorted) selection so that order differences don't cause cache misses. + cache_key = "compatible:" + hashlib.md5( + json.dumps( + { + "uid": datasource.uid, + "m": sorted(selected_metrics), + "d": sorted(selected_dimensions), + }, + sort_keys=True, + ).encode() + ).hexdigest() + + if (cached := cache_manager.data_cache.get(cache_key)) is not None: + return self.response(200, result=cached) + + result = { + "compatible_metrics": datasource.get_compatible_metrics( + selected_metrics, selected_dimensions + ), + "compatible_dimensions": datasource.get_compatible_dimensions( + selected_metrics, selected_dimensions + ), + } + + timeout = datasource.cache_timeout or app.config.get( + "CACHE_DEFAULT_TIMEOUT", 300 ) + cache_manager.data_cache.set(cache_key, result, timeout=timeout) + + return self.response(200, result=result) @expose("/", methods=("GET",)) @protect() diff --git a/superset/semantic_layers/models.py b/superset/semantic_layers/models.py index 4d2eedacb66..f8af9269590 100644 --- a/superset/semantic_layers/models.py +++ b/superset/semantic_layers/models.py @@ -404,6 +404,9 @@ class SemanticView(AuditMixinNullable, Model): def is_rls_supported(self) -> bool: return False + def raise_for_access(self) -> None: + """No-op: semantic view access control is not yet implemented.""" + @property def query_language(self) -> str | None: return None