Dynamic columns/metrics

This commit is contained in:
Beto Dealmeida
2026-04-22 14:34:51 -04:00
parent a06ae6c7b6
commit f1fdc8a524
9 changed files with 250 additions and 30 deletions

View File

@@ -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;

View File

@@ -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 (
<DatasourceItemContainer data-test="DatasourcePanelDragOption" ref={drag}>
<DatasourceItemContainer
data-test="DatasourcePanelDragOption"
ref={drag}
style={{
opacity: isCompatible ? 1 : 0.35,
cursor: isCompatible ? 'grab' : 'not-allowed',
}}
>
{type === DndItemType.Column ? (
<StyledColumnOption column={value as ColumnMeta} {...optionProps} />
) : (

View File

@@ -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

View File

@@ -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}
>
<OptionWrapper
key={`column-${idx}`}
@@ -444,7 +454,7 @@ function DndColumnMetricSelect(props: DndColumnMetricSelectProps) {
togglePopover={toggleColumnPopover}
closePopover={closeColumnPopover}
isTemporal={false}
disabledTabs={disabledTabs}
disabledTabs={effectiveDisabledTabs}
metrics={savedMetrics}
selectedMetrics={selectedMetrics}
>

View File

@@ -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))
) {

View File

@@ -415,21 +415,25 @@ export default class AdhocFilterEditPopover extends Component<
</ErrorBoundary>
),
},
{
key: ExpressionTypes.Sql,
label: t('Custom SQL'),
children: (
<ErrorBoundary>
<AdhocFilterEditPopoverSqlTabContent
adhocFilter={this.state.adhocFilter}
onChange={this.onAdhocFilterChange}
options={this.props.options}
height={this.state.height}
datasource={datasource}
/>
</ErrorBoundary>
),
},
...(datasource?.type === 'semantic_view'
? []
: [
{
key: ExpressionTypes.Sql,
label: t('Custom SQL'),
children: (
<ErrorBoundary>
<AdhocFilterEditPopoverSqlTabContent
adhocFilter={this.state.adhocFilter}
onChange={this.onAdhocFilterChange}
options={this.props.options}
height={this.state.height}
datasource={datasource}
/>
</ErrorBoundary>
),
},
]),
]}
/>
{hasDeckSlices && (

View File

@@ -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 {

View File

@@ -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()

View File

@@ -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