fix(semantic-layer): require explicit member identity reselection (#44370)

This commit is contained in:
Mike Bridge
2026-10-01 23:10:18 -06:00
committed by GitHub
parent 6d97cde59a
commit b94f88ffbb
67 changed files with 2490 additions and 143 deletions

View File

@@ -910,6 +910,54 @@ substitution is deliberately not blanked so the broken link is visible rather th
silently truncated. Use `{datasource_id}` (still supported) to identify the dataset
to your access-request system, and resolve the name there.
### Semantic member selection versions
Semantic providers can declare `selection_identity_version` when changing their
member identity encoding. Cube's stable-member-ID adapter requires
`cube-member-id-v1`. Deploy the compatible core library, host, frontend and Cube
adapter together; do not roll back to title-keyed code after saving ID-based charts.
Saved Cube selections without this version are rejected, even when a saved title
happens to equal a member ID. No historical title mapping is recovered. Open each
chart in Explore, choose **Start field selection**, reselect its metrics,
dimensions, filters and sorting, then save. Reset also clears field-dependent
formatting; it does not overwrite the saved chart until Save is chosen. Native
filters and chart customizations have a separate reset in their configuration
form that clears fields, pre-filters, sorting, defaults and dependencies.
Dashboard filters retain their own saved generation. Old filter state and
unversioned external member overrides cannot borrow a chart's version. Recreate
old dashboard permalink/filter state after reselection. Cross-view overlays are
rejected unless their source identity matches the receiving semantic view;
matching display titles are not proof of matching members.
Dynamic group-by display controls are unsupported on versioned semantic views,
including Cube. Remove those charts from the control's scope; resetting or
reselecting the group-by fields cannot repair this control's missing selection
identity evidence. Static chart group-by selections remain available after the
chart's explicit reselection. Supporting dynamic group-by requires preserving the
version in target rebuilds and recording the source identity in the emitted mask;
that follow-up is not included here.
Matrixify All mode with A to Z or Z to A sorting cannot populate or refresh values
for versioned semantic views. Saved values are preserved; for a new selection,
switch to Members and enter values manually. The control explains this limitation
when suggestions are unavailable.
The marker is a compatibility contract, not an authorization credential. API
clients must rebuild their selections from current member IDs before supplying
`extras.semantic_selection_version` on each query. Providers without a required
version retain their existing behavior. No metadata-database migration or
automatic rewrite of saved charts is performed.
Programmatic clients must explicitly select current member IDs. Chart-data API
queries send `extras.semantic_selection_version`; name-based datasource queries
and MCP `get_table` requests send top-level `semantic_selection_version`.
Discover the value from datasource metadata (or each external metric returned by
MCP `list_metrics`). The server never adds a missing marker from metadata.
Explore query controls remain unavailable until explicit field initialization,
so legacy filter subjects cannot trigger value suggestions before reselection.
### MCP tool results preserve stored string values
Structured MCP tool results no longer add `<UNTRUSTED-CONTENT>` wrappers or

View File

@@ -0,0 +1,62 @@
---
title: Semantic member selections
---
<!--
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.
-->
# Semantic member selection versions
Semantic providers can declare `selection_identity_version` when changing their
member identity encoding. Cube's stable-member-ID adapter requires
`cube-member-id-v1`. Deploy the compatible core library, host, frontend and Cube
adapter together; do not roll back to title-keyed code after saving ID-based charts.
Saved Cube selections without this version are rejected, even when a saved title
happens to equal a member ID. No historical title mapping is recovered. Open each
chart in Explore, choose **Start field selection**, reselect its metrics,
dimensions, filters and sorting, then save. Reset also clears field-dependent
formatting; it does not overwrite the saved chart until Save is chosen. Native
filters and chart customizations have a separate reset in their configuration
form that clears fields, pre-filters, sorting, defaults and dependencies.
Dashboard filters retain their own saved generation. Old filter state and
unversioned external member overrides cannot borrow a chart's version. Recreate
old dashboard permalink/filter state after reselection. Cross-view overlays are
rejected unless their source identity matches the receiving semantic view;
matching display titles are not proof of matching members.
The marker is a compatibility contract, not an authorization credential. API
clients must rebuild their selections from current member IDs before supplying
`extras.semantic_selection_version` on each query. Providers without a required
version retain their existing behavior. No metadata-database migration or
automatic rewrite of saved charts is performed.
Programmatic clients must explicitly select current member IDs. Chart-data API
queries send `extras.semantic_selection_version`; name-based datasource queries
and MCP `get_table` requests send top-level `semantic_selection_version`.
Discover the value from datasource metadata (or each external metric returned by
MCP `list_metrics`). The server never adds a missing marker from metadata.
Explore query controls remain unavailable until explicit field initialization,
so legacy filter subjects cannot trigger value suggestions before reselection.
For versioned semantic views, Matrixify **All** mode with **A to Z** or **Z to A**
sorting cannot populate or refresh dimension values from suggestions. Existing
saved values are preserved. For a new selection, switch to **Members** and enter
values manually; the control displays this guidance when suggestions are unavailable.

View File

@@ -1245,6 +1245,11 @@
],
"type": "string"
},
"semantic_selection_version": {
"description": "Identity encoding used for semantic member selections.",
"nullable": true,
"type": "string"
},
"time_grain_sqla": {
"description": "To what level of granularity should the temporal column be aggregated. Supports [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601#Durations) durations.",
"example": "P1D",
@@ -7087,6 +7092,12 @@
"arrow"
]
},
"semantic_selection_version": {
"default": null,
"description": "Identity version from datasource metadata, after selecting its current metric and dimension IDs. Never infer legacy IDs.",
"nullable": true,
"type": "string"
},
"time_column": {
"default": null,
"description": "Temporal column the time range applies to. Inferred from the datasource when omitted.",
@@ -25329,6 +25340,13 @@
]
},
"type": "array"
},
"suggestions_status": {
"description": "Suggestions are disabled for versioned semantic views; enter values manually.",
"enum": [
"unavailable_versioned_view"
],
"type": "string"
}
},
"type": "object"

View File

@@ -236,3 +236,4 @@ class SemanticQuery:
limit: int | None = None
offset: int | None = None
group_limit: GroupLimit | None = None
selection_identity_version: str | None = None

View File

@@ -50,6 +50,20 @@ class SemanticView(ABC):
# consumers can rely on the attribute existing and degrade to the
# conservative (Saved-only) picker for views that declare nothing.
features: frozenset[SemanticViewFeature] = frozenset()
selection_identity_version: str | None = None
def validate_selection_version(self, version: object) -> None:
"""Reject selections made under a different member identity contract."""
if (
self.selection_identity_version is not None
and version != self.selection_identity_version
):
raise ValueError(
"Saved semantic selections use an older identity format. "
"Reset the chart selections, explicitly reselect its metrics "
"and dimensions, and save the chart. Display titles cannot be "
"automatically mapped to member IDs."
)
# Implementations must expose a display name for the view.
# Declared here as a type annotation (not abstract) so that existing

View File

@@ -45,6 +45,13 @@ export const datasourceAndVizType: ControlPanelSectionConfig = {
['datasource'],
['viz_type'],
[
{
name: 'semantic_selection_version',
config: {
type: 'HiddenControl',
hidden: true,
},
},
{
name: 'slice_id',
config: {

View File

@@ -70,6 +70,7 @@ export type ColumnMeta = Omit<Column, 'id'> & {
} & AnyDict;
export interface Dataset {
semantic_selection_version?: string | null;
id: number;
type: DatasourceType;
columns: ColumnMeta[];

View File

@@ -18,6 +18,7 @@
*/
/* eslint-disable camelcase */
import { getSemanticSelectionSources } from './semanticSelection';
import { TimeGranularity, QueryFormData } from '@superset-ui/core';
import {
AppliedTimeExtras,
@@ -72,6 +73,27 @@ export default function extractExtras(formData: QueryFormData): ExtractedExtra {
}
});
const selectionSources = [
...getSemanticSelectionSources(formData.extra_form_data),
...(formData.semantic_selection_sources ?? []),
];
const hasUnversionedExtras = (formData.extra_filters ?? []).some(
filter =>
!['__time_range', '__time_grain', '__time_compare'].includes(filter.col),
);
if (typeof formData.semantic_selection_version === 'string') {
const validSources =
!hasUnversionedExtras &&
selectionSources.every(
source =>
source.datasource === formData.datasource &&
source.version === formData.semantic_selection_version,
);
extras.semantic_selection_version = validSources
? formData.semantic_selection_version
: 'unverified-external-selections';
}
// SQL
extras.time_grain_sqla = extract.time_grain_sqla || formData.time_grain_sqla;
extract.granularity =

View File

@@ -46,3 +46,5 @@ export { default as makeApi } from './api/v1/makeApi';
// API Callers
export { ApiLegacy, ApiV1 };
export { getSemanticSelectionSources } from './semanticSelection';

View File

@@ -0,0 +1,42 @@
/*
* 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 { ExtraFormData, SemanticSelectionSource } from './types/QueryFormData';
/** Preserve missing provenance as a source that cannot be certified by a later merge. */
export function getSemanticSelectionSources(
extra: ExtraFormData = {},
): SemanticSelectionSource[] {
if (extra.semantic_selection_sources?.length)
return extra.semantic_selection_sources;
const references = [
extra.filters,
extra.adhoc_filters,
extra.interactive_drilldown,
extra.interactive_groupby,
extra.interactive_highlight,
];
const hasMembers =
references.some(value => value && value.length > 0) ||
!!extra.granularity_sqla ||
!!extra.granularity ||
!!extra.time_column ||
!!(extra.custom_form_data && Object.keys(extra.custom_form_data).length);
return hasMembers ? [{ datasource: '', version: null }] : [];
}

View File

@@ -37,6 +37,7 @@ export interface NativeFilterScope {
/** The target of a filter is the datasource/column being filtered */
export interface NativeFilterTarget {
semantic_selection_version?: string;
datasetId: number;
column: NativeFilterColumn;
datasourceType?: DatasourceType;

View File

@@ -63,6 +63,7 @@ export type QueryObjectFilterClause =
| UnaryQueryObjectFilterClause;
export type QueryObjectExtras = Partial<{
semantic_selection_version?: string;
/** HAVING condition for SQLAlchemy */
having?: string;
relative_start?: string;

View File

@@ -140,12 +140,22 @@ export type ExtraFormDataOverrideRegular = Partial<
export type ExtraFormDataOverride = ExtraFormDataOverrideRegular &
ExtraFormDataOverrideExtras;
export type ExtraFormData = ExtraFormDataAppend & ExtraFormDataOverride;
export type SemanticSelectionSource = {
datasource: string;
version: string | null;
};
export type ExtraFormData = ExtraFormDataAppend &
ExtraFormDataOverride & {
semantic_selection_sources?: SemanticSelectionSource[];
};
// Type signature for formData shared by all viz types
// It will be gradually filled out as we build out the query object
export interface BaseFormData extends TimeRange, FormDataResidual {
semantic_selection_version?: string;
semantic_selection_sources?: SemanticSelectionSource[];
/** datasource identifier ${id}_${type} */
datasource: string;
/**

View File

@@ -150,3 +150,65 @@ describe('extractExtras', () => {
});
});
});
test('requires selection provenance even when a historical title equals a member ID', () => {
const form = {
datasource: '7__semantic_view',
viz_type: 'table',
metrics: ['Orders.b'],
};
expect(extractExtras(form).extras.semantic_selection_version).toBeUndefined();
expect(
extractExtras({ ...form, semantic_selection_version: 'cube-member-id-v1' })
.extras.semantic_selection_version,
).toBe('cube-member-id-v1');
});
test('a chart version cannot certify a stale or foreign external filter', () => {
const form = {
datasource: '7__semantic_view',
viz_type: 'table',
semantic_selection_version: 'cube-member-id-v1',
};
const filter = { col: 'Orders.b', op: 'IN' as const, val: ['x'] };
expect(
extractExtras({ ...form, extra_form_data: { filters: [filter] } }).extras
.semantic_selection_version,
).toBe('unverified-external-selections');
expect(
extractExtras({
...form,
extra_form_data: {
filters: [filter],
semantic_selection_sources: [
{ datasource: '7__semantic_view', version: 'cube-member-id-v1' },
],
},
}).extras.semantic_selection_version,
).toBe('cube-member-id-v1');
expect(
extractExtras({
...form,
extra_form_data: {
filters: [filter],
semantic_selection_sources: [
{ datasource: '8__semantic_view', version: 'cube-member-id-v1' },
],
},
}).extras.semantic_selection_version,
).toBe('unverified-external-selections');
});
test.each(['__time_col', '__granularity', 'Orders.status'])(
'legacy external member %s cannot borrow the chart generation',
col => {
expect(
extractExtras({
datasource: '7__semantic_view',
viz_type: 'table',
semantic_selection_version: 'cube-member-id-v1',
extra_filters: [{ col, op: 'IN', val: ['Orders.created'] }],
}).extras.semantic_selection_version,
).toBe('unverified-external-selections');
},
);

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { render } from 'spec/helpers/testing-library';
import { render, fireEvent, screen } from 'spec/helpers/testing-library';
import {
ChartMetadata,
getChartMetadataRegistry,
@@ -24,6 +24,7 @@ import {
JsonObject,
FeatureFlag,
FeatureFlagMap,
DataMask,
} from '@superset-ui/core';
import ChartRenderer, {
ChartRendererProps,
@@ -54,6 +55,22 @@ jest.mock('@superset-ui/core', () => ({
]?.(),
)}
>
<button
type="button"
onClick={() =>
(
props.hooks as { setDataMask: (mask: DataMask) => void }
).setDataMask({
extraFormData: {
semantic_selection_sources: [
{ datasource: 'foreign__semantic_view', version: null },
],
},
})
}
>
Emit semantic mask
</button>
{JSON.stringify(postTransformProps(props).formData)}
</div>
),
@@ -483,3 +500,36 @@ test('does not render chart during loading when last data has errors', () => {
const { queryByTestId } = render(<ChartRenderer {...props} />);
expect(queryByTestId('mock-super-chart')).not.toBeInTheDocument();
});
test('chart mask updates preserve incoming and query selection provenance', () => {
const updateDataMask = jest.fn();
render(
<ChartRenderer
{...requiredProps}
latestQueryFormData={{
datasource: '7__semantic_view',
viz_type: 'table',
semantic_selection_version: 'cube-member-id-v1',
semantic_selection_sources: [
{ datasource: 'query__semantic_view', version: null },
],
}}
actions={
{ ...mockActions, updateDataMask } as ChartRendererProps['actions']
}
/>,
);
fireEvent.click(screen.getByRole('button', { name: 'Emit semantic mask' }));
expect(updateDataMask).toHaveBeenCalledWith(
1,
expect.objectContaining({
extraFormData: expect.objectContaining({
semantic_selection_sources: [
{ datasource: 'foreign__semantic_view', version: null },
{ datasource: 'query__semantic_view', version: null },
{ datasource: '7__semantic_view', version: 'cube-member-id-v1' },
],
}),
}),
);
});

View File

@@ -381,9 +381,28 @@ function ChartRendererComponent({
const setDataMaskCallback = useCallback(
(dataMask: DataMask) => {
actions?.updateDataMask?.(chartId, dataMask);
const sourceForm = latestQueryFormData ?? formData;
actions?.updateDataMask?.(
chartId,
sourceForm.datasource?.endsWith('__semantic_view')
? {
...dataMask,
extraFormData: {
...dataMask.extraFormData,
semantic_selection_sources: [
...(dataMask.extraFormData?.semantic_selection_sources ?? []),
...(sourceForm.semantic_selection_sources ?? []),
{
datasource: sourceForm.datasource,
version: sourceForm.semantic_selection_version ?? null,
},
],
},
}
: dataMask,
);
},
[actions, chartId],
[actions, chartId, latestQueryFormData, formData],
);
// Hooks object - memoized

View File

@@ -16,11 +16,16 @@
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen, waitFor } from 'spec/helpers/testing-library';
import {
render,
screen,
waitFor,
userEvent,
} from 'spec/helpers/testing-library';
import { Provider } from 'react-redux';
import configureStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import { NativeFilterType } from '@superset-ui/core';
import { NativeFilterType, DatasourceType } from '@superset-ui/core';
import type { Filter } from '@superset-ui/core';
import FilterValue from './FilterValue';
@@ -343,3 +348,36 @@ test('skips data fetch when cascade parent filters have no values selected', ()
expect(mockRequestChartData).not.toHaveBeenCalled();
});
test('legacy permalink values require an explicit reset before the plugin can emit current identity', async () => {
mockRequestChartData.mockResolvedValue([{ data: [] }]);
const onFilterSelectionChange = jest.fn();
const filter = {
...createMockFilter({
targets: [
{
datasetId: 7,
datasourceType: DatasourceType.SemanticView,
column: { name: 'Orders.status' },
semantic_selection_version: 'cube-member-id-v1',
},
],
}),
dataMask: { filterState: { value: ['Orders.status'] }, extraFormData: {} },
};
renderFilterValue({ filter, onFilterSelectionChange });
expect(screen.getByText('Reselect saved filter values')).toBeInTheDocument();
expect(screen.queryByTestId('mock-super-chart')).not.toBeInTheDocument();
await userEvent.click(
screen.getByRole('button', { name: 'Reset and reselect values' }),
);
expect(onFilterSelectionChange).toHaveBeenCalledWith(filter, {
filterState: {},
ownState: {},
extraFormData: {
semantic_selection_sources: [
{ datasource: '7__semantic_view', version: 'cube-member-id-v1' },
],
},
});
});

View File

@@ -27,6 +27,7 @@ import {
} from 'react';
import { t } from '@apache-superset/core/translation';
import { Alert } from '@apache-superset/core/components';
import {
ChartDataResponseResult,
Behavior,
@@ -38,6 +39,7 @@ import {
SuperChart,
ClientErrorObject,
getClientErrorObject,
getSemanticSelectionSources,
isChartCustomization,
} from '@superset-ui/core';
import { styled, SupersetTheme } from '@apache-superset/core/theme';
@@ -46,7 +48,7 @@ import { useDispatch, useSelector, shallowEqual } from 'react-redux';
import { isEqual, isEqualWith } from 'lodash-es';
import { requestChartDataResolved } from 'src/components/Chart/chartAction';
import { ErrorAlert, ErrorMessageWithStackTrace } from 'src/components';
import { Loading, Constants, Flex } from '@superset-ui/core/components';
import { Loading, Constants, Flex, Button } from '@superset-ui/core/components';
import { useAsyncModeOverride } from 'src/utils/asyncMode';
import { FilterBarOrientation, RootState } from 'src/dashboard/types';
import {
@@ -163,10 +165,12 @@ const FilterValue: FC<FilterValueProps> = ({
const {
datasetId,
datasourceType,
semantic_selection_version,
column = {},
}: Partial<{
datasetId: number;
datasourceType: DatasourceType;
semantic_selection_version?: string;
column: { name?: string };
}> = target || {};
const groupby = column?.name;
@@ -203,6 +207,7 @@ const FilterValue: FC<FilterValueProps> = ({
...filter,
datasetId,
datasourceType,
semantic_selection_version,
dependencies,
groupby,
adhoc_filters: adhocFilters,
@@ -326,8 +331,31 @@ const FilterValue: FC<FilterValueProps> = ({
}, [inputRef, outlinedFilterId, lastUpdated, filter.id, overflow]);
const setDataMask = useCallback(
(dataMask: DataMask) => onFilterSelectionChange(filter, dataMask),
[filter, onFilterSelectionChange],
(dataMask: DataMask) =>
onFilterSelectionChange(
filter,
datasourceType === DatasourceType.SemanticView
? {
...dataMask,
extraFormData: {
...dataMask.extraFormData,
semantic_selection_sources: [
{
datasource: `${datasetId}__${datasourceType || DatasourceType.Table}`,
version: semantic_selection_version ?? null,
},
],
},
}
: dataMask,
),
[
filter,
onFilterSelectionChange,
datasetId,
datasourceType,
semantic_selection_version,
],
);
const setFocusedFilter = useCallback(() => {
@@ -404,6 +432,42 @@ const FilterValue: FC<FilterValueProps> = ({
[orientation, overflow],
);
const selectionSources = getSemanticSelectionSources(
filter.dataMask?.extraFormData,
);
const hasSavedSelection =
filter.dataMask?.filterState?.value !== undefined ||
selectionSources.length > 0;
const staleSelection =
semantic_selection_version &&
hasSavedSelection &&
(selectionSources.length === 0 ||
selectionSources.some(
source =>
source.datasource !== `${datasetId}__${datasourceType}` ||
source.version !== semantic_selection_version,
));
if (staleSelection) {
return (
<Alert
type="warning"
message={t('Reselect saved filter values')}
description={t(
'This saved filter state predates the member-ID format. Reset it and explicitly choose its values again.',
)}
action={
<Button
onClick={() =>
setDataMask({ filterState: {}, ownState: {}, extraFormData: {} })
}
>
{t('Reset and reselect values')}
</Button>
}
/>
);
}
if (error) {
return (
<ErrorMessageWithStackTrace

View File

@@ -55,6 +55,7 @@ import {
memo,
} from 'react';
import rison from 'rison';
import { Alert } from '@apache-superset/core/components';
import {
PluginFilterSelectCustomizeProps,
SelectFilterOperatorType,
@@ -63,6 +64,7 @@ import { useSelector } from 'react-redux';
import { requestChartDataResolved } from 'src/components/Chart/chartAction';
import {
Constants,
Button,
FormItem,
type FormInstance,
Collapse,
@@ -573,8 +575,17 @@ const FiltersConfigForm = (
newFormData.extra_form_data = dependenciesDefaultValues;
const selectionsReset =
!!formFilter?.semantic_selection_version &&
formFilter.semantic_selection_version !==
(filterToEdit ?? customizationToEdit)?.targets?.[0]
?.semantic_selection_version;
const [hasDefaultValue, isRequired, defaultValueTooltip, setHasDefaultValue] =
useDefaultValue(formFilter, filterToEdit, customizationToEdit);
useDefaultValue(
formFilter,
selectionsReset ? undefined : filterToEdit,
selectionsReset ? undefined : customizationToEdit,
);
const showDataset =
!datasetId || datasetDetails || formFilter?.dataset?.label;
@@ -588,13 +599,14 @@ const FiltersConfigForm = (
);
const hasPreFilter =
!!formFilter?.adhoc_filters ||
!!formFilter?.adhoc_filters?.length ||
!!formFilter?.time_range ||
!!filterToEdit?.adhoc_filters?.length ||
!!filterToEdit?.time_range;
(!selectionsReset &&
(!!filterToEdit?.adhoc_filters?.length || !!filterToEdit?.time_range));
const hasTimeGrainPreFilter = !!(
formFilterWithTimeGrains?.time_grains?.length || savedTimeGrains?.length
formFilterWithTimeGrains?.time_grains?.length ||
(!selectionsReset && savedTimeGrains?.length)
);
const hasEnableSingleValue =
@@ -723,10 +735,11 @@ const FiltersConfigForm = (
const defaultToFirstItem = formFilter?.controlValues?.defaultToFirstItem;
const initialDefaultValue =
!selectionsReset &&
itemTypeField ===
(isChartCustomization
? customizationToEdit?.filterType
: filterToEdit?.filterType)
(isChartCustomization
? customizationToEdit?.filterType
: filterToEdit?.filterType)
? isChartCustomization
? customizationToEdit?.defaultDataMask
: filterToEdit?.defaultDataMask
@@ -760,32 +773,40 @@ const FiltersConfigForm = (
if (datasetId) {
if (datasourceType === DatasourceType.SemanticView) {
fetchSemanticViewStructure(datasetId)
.then(({ name: svName, dimensions, metrics: svMetrics }) => {
const columns = semanticViewDimensionsToColumns(dimensions);
// The /structure wire carries no metric uuid, and this state's
// consumers key on metric_name/verbose_name without reading
// uuid — so the cast is narrowed to exactly that one absent
// property; every other field stays compiler-checked.
const mappedMetrics = svMetrics.map(
(m: { name: string; definition: string }) => ({
metric_name: m.name,
expression: m.definition,
}),
) as Omit<Metric, 'uuid'>[] as Metric[];
setMetrics(mappedMetrics);
setDatasetDetails({
columns,
metrics: mappedMetrics,
datasource_type: DatasourceType.SemanticView,
type: DatasourceType.SemanticView,
filter_select: true,
filter_select_enabled: true,
time_grain_sqla: [],
main_dttm_col: null,
id: datasetId,
table_name: svName,
});
})
.then(
({
name: svName,
dimensions,
metrics: svMetrics,
semantic_selection_version,
}) => {
const columns = semanticViewDimensionsToColumns(dimensions);
// The /structure wire carries no metric uuid, and this state's
// consumers key on metric_name/verbose_name without reading
// uuid — so the cast is narrowed to exactly that one absent
// property; every other field stays compiler-checked.
const mappedMetrics = svMetrics.map(
(m: { name: string; definition: string }) => ({
metric_name: m.name,
expression: m.definition,
}),
) as Omit<Metric, 'uuid'>[] as Metric[];
setMetrics(mappedMetrics);
setDatasetDetails({
semantic_selection_version,
columns,
metrics: mappedMetrics,
datasource_type: DatasourceType.SemanticView,
type: DatasourceType.SemanticView,
filter_select: true,
filter_select_enabled: true,
time_grain_sqla: [],
main_dttm_col: null,
id: datasetId,
table_name: svName,
});
},
)
.catch((response: SupersetApiError) => {
addDangerToast(response.message);
});
@@ -961,6 +982,52 @@ const FiltersConfigForm = (
forceRender: true,
children: (
<>
<FormItem
hidden
name={['filters', filterId, 'semantic_selection_version']}
initialValue={
(filterToEdit ?? customizationToEdit)?.targets?.[0]
?.semantic_selection_version
}
/>
{datasetDetails?.semantic_selection_version &&
formFilter?.semantic_selection_version !==
datasetDetails.semantic_selection_version && (
<Alert
type="warning"
message={t('Choose current semantic filter fields')}
description={t(
'Start field selection using current member IDs. This clears any existing field selections, pre-filters, sorting, defaults and dependencies. Saved display titles cannot be recovered automatically.',
)}
action={
<Button
onClick={() => {
setNativeFilterFieldValues(form, filterId, {
semantic_selection_version:
datasetDetails.semantic_selection_version,
column: undefined,
adhoc_filters: [],
time_range: undefined,
time_grains: [],
preFilter: false,
preFilterTimegrain: false,
granularity_sqla: undefined,
sortMetric: null,
defaultDataMask: {},
dependencies: [],
defaultValue: undefined,
controlValues: {},
});
setHasDefaultValue(false);
forceUpdate();
formChanged();
}}
>
{t('Start field selection')}
</Button>
}
/>
)}
<StyledSettings>
<StyledContainer>
<StyledFormItem
@@ -1173,6 +1240,7 @@ const FiltersConfigForm = (
datasourceType: newDatasourceType,
defaultDataMask: null,
column: null,
semantic_selection_version: undefined,
});
}
forceUpdate();
@@ -1256,6 +1324,7 @@ const FiltersConfigForm = (
name={['filters', filterId, 'preFilter']}
>
<CollapsibleControl
key={`pre-filter-${selectionsReset}`}
initialValue={hasPreFilter}
title={t('Pre-filter available values')}
tooltip={t(`Add filter clauses to control the filter's source query,
@@ -1338,7 +1407,8 @@ const FiltersConfigForm = (
</StyledLabel>
}
initialValue={
filterToEdit?.time_range ||
(!selectionsReset &&
filterToEdit?.time_range) ||
t('No filter')
}
required={!hasAdhoc}
@@ -1385,6 +1455,7 @@ const FiltersConfigForm = (
]}
>
<CollapsibleControl
key={`time-grain-filter-${selectionsReset}`}
initialValue={hasTimeGrainPreFilter}
title={t('Pre-filter available values')}
tooltip={t(
@@ -1408,7 +1479,11 @@ const FiltersConfigForm = (
filterId,
'time_grains',
]}
initialValue={savedTimeGrains}
initialValue={
selectionsReset
? undefined
: savedTimeGrains
}
{...getFiltersConfigModalTestId(
'time-grain-allowlist',
)}

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { Preset } from '@superset-ui/core';
import { Preset, DatasourceType, Filter } from '@superset-ui/core';
import fetchMock from 'fetch-mock';
import chartQueries from 'spec/fixtures/mockChartQueries';
import { dashboardLayout } from 'spec/fixtures/mockDashboardLayout';
@@ -1121,3 +1121,104 @@ test('toggles "Filter has default value" to show and hide the Default Value cont
expect(screen.queryByText(/^default value$/i)).not.toBeInTheDocument();
});
});
test('semantic filter reset requires reselection and survives save and reopen', async () => {
fetchMock.get('glob:*/api/v1/semantic_view/987/structure', {
result: {
name: 'Orders',
semantic_selection_version: 'cube-member-id-v1',
dimensions: [{ name: 'Orders.status', type: 'string' }],
metrics: [],
},
});
const filter = {
...buildNativeFilter('NATIVE_FILTER-identity', 'Legacy', []),
targets: [
{
datasetId: 987,
datasourceType: DatasourceType.SemanticView,
column: { name: 'Orders.status' },
},
],
time_range: 'Last week',
time_grains: ['P1D'],
defaultDataMask: {
filterState: { value: ['old default'] },
extraFormData: {
filters: [
{ col: 'Orders.status', op: 'IN' as const, val: ['old default'] },
],
},
},
};
const state = {
...defaultState(),
dashboardInfo: { metadata: { native_filter_configuration: [filter] } },
dashboardLayout,
};
const onSave = jest
.fn<
ReturnType<FiltersConfigModalProps['onSave']>,
Parameters<FiltersConfigModalProps['onSave']>
>()
.mockResolvedValue(undefined);
const { unmount } = defaultRender(state, {
...props,
onSave,
createNewOnOpen: false,
});
await userEvent.click(
await screen.findByRole('button', { name: 'Start field selection' }),
);
await waitFor(() =>
expect(
screen.queryByText('Choose current semantic filter fields'),
).not.toBeInTheDocument(),
);
expect(getCheckbox(PRE_FILTER_REGEX)).not.toBeChecked();
expect(getCheckbox(DEFAULT_VALUE_REGEX)).not.toBeChecked();
expect(screen.queryByText('old default')).not.toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: SAVE_REGEX }));
expect(await screen.findByText(COLUMN_REQUIRED_REGEX)).toBeInTheDocument();
expect(onSave).not.toHaveBeenCalled();
// This ID also spelled the legacy title; only explicit reselection certifies it.
await userEvent.click(
screen.getByRole('combobox', { name: 'Column select' }),
);
await userEvent.click(await screen.findByText('Orders.status'));
// Column validation clears the previous save error asynchronously.
await waitFor(() =>
expect(screen.getByRole('button', { name: SAVE_REGEX })).toBeEnabled(),
);
await userEvent.click(screen.getByRole('button', { name: SAVE_REGEX }));
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
const changes = onSave.mock.calls[0][0].filterChanges;
expect(changes?.modified).toHaveLength(1);
const saved = changes?.modified[0];
const reopened: Filter = JSON.parse(JSON.stringify(saved));
expect(reopened.targets).toEqual([
{
datasetId: 987,
datasourceType: DatasourceType.SemanticView,
column: { name: 'Orders.status' },
semantic_selection_version: 'cube-member-id-v1',
},
]);
expect(reopened.time_range).toBeUndefined();
expect(reopened.time_grains).toBeUndefined();
expect(reopened.defaultDataMask.filterState?.value).toBeUndefined();
expect(reopened.defaultDataMask.extraFormData?.filters).toBeUndefined();
unmount();
const reopenedState = {
...state,
dashboardInfo: { metadata: { native_filter_configuration: [reopened] } },
};
defaultRender(reopenedState, { ...props, createNewOnOpen: false });
expect(await screen.findByText('Orders.status')).toBeInTheDocument();
expect(
screen.queryByRole('button', { name: 'Start field selection' }),
).not.toBeInTheDocument();
expect(screen.queryByText('old default')).not.toBeInTheDocument();
});

View File

@@ -17,7 +17,10 @@
* under the License.
*/
import { DatasourceType } from '@superset-ui/core';
import { buildNativeFilterTarget } from './buildTarget';
import {
buildNativeFilterTarget,
buildNativeFilterDefaultDataMask,
} from './buildTarget';
test('returns an empty target when no dataset is selected', () => {
expect(buildNativeFilterTarget({})).toEqual({});
@@ -82,3 +85,42 @@ test('omits datasourceType when there is no dataset', () => {
buildNativeFilterTarget({ datasourceType: DatasourceType.Table }),
).toEqual({});
});
test('persists only explicitly supplied selection generation across a target round trip', () => {
const old = buildNativeFilterTarget({
dataset: 7,
datasourceType: DatasourceType.SemanticView,
column: 'Orders.b',
});
expect(old).not.toHaveProperty('semantic_selection_version');
const current = buildNativeFilterTarget({
dataset: 7,
datasourceType: DatasourceType.SemanticView,
column: 'Orders.b',
semantic_selection_version: 'cube-member-id-v1',
});
expect(JSON.parse(JSON.stringify(current)).semantic_selection_version).toBe(
'cube-member-id-v1',
);
});
test('only an explicitly versioned semantic form certifies its new default', () => {
const mask = {
extraFormData: {
filters: [{ col: 'Orders.status', op: 'IN' as const, val: ['paid'] }],
},
};
const form = { dataset: 7, datasourceType: DatasourceType.SemanticView };
expect(buildNativeFilterDefaultDataMask(form, mask)).toEqual(mask);
expect(
buildNativeFilterDefaultDataMask(
{ ...form, semantic_selection_version: 'cube-member-id-v1' },
mask,
).extraFormData,
).toEqual({
...mask.extraFormData,
semantic_selection_sources: [
{ datasource: '7__semantic_view', version: 'cube-member-id-v1' },
],
});
});

View File

@@ -16,7 +16,11 @@
* specific language governing permissions and limitations
* under the License.
*/
import { DatasourceType, NativeFilterTarget } from '@superset-ui/core';
import {
DataMask,
DatasourceType,
NativeFilterTarget,
} from '@superset-ui/core';
/**
* Minimal shape of the form inputs that drive ``NativeFilterTarget``
@@ -27,6 +31,7 @@ export interface TargetFormInputs {
dataset?: { value: number } | number;
datasourceType?: DatasourceType;
column?: string;
semantic_selection_version?: string;
}
/**
@@ -61,5 +66,35 @@ export function buildNativeFilterTarget(
target.column = { name: formInputs.column };
}
if (formInputs.dataset != null && formInputs.semantic_selection_version) {
target.semantic_selection_version = formInputs.semantic_selection_version;
}
return target;
}
/** Retain the explicit saved generation on defaults produced by the reset form. */
export function buildNativeFilterDefaultDataMask(
formInputs: TargetFormInputs,
mask: DataMask,
): DataMask {
const target = buildNativeFilterTarget(formInputs);
if (
target.datasourceType !== DatasourceType.SemanticView ||
!target.semantic_selection_version ||
(mask.filterState?.value === undefined &&
!Object.keys(mask.extraFormData ?? {}).length)
)
return mask;
return {
...mask,
extraFormData: {
...mask.extraFormData,
semantic_selection_sources: [
{
datasource: `${target.datasetId}__${target.datasourceType}`,
version: target.semantic_selection_version,
},
],
},
};
}

View File

@@ -31,7 +31,10 @@ import {
NativeFiltersFormItem,
NativeFilterDivider,
} from '../types';
import { buildNativeFilterTarget } from './buildTarget';
import {
buildNativeFilterTarget,
buildNativeFilterDefaultDataMask,
} from './buildTarget';
type CustomizationFormInput =
| ChartCustomizationsFormItem
@@ -111,7 +114,10 @@ function transformFormInput(
targets: [buildCustomizationTarget(formInputs)],
scope: formInputs.scope || defaultScope,
controlValues: formInputs.controlValues ?? {},
defaultDataMask: formInputs.defaultDataMask ?? {},
defaultDataMask: buildNativeFilterDefaultDataMask(
formInputs,
formInputs.defaultDataMask ?? {},
),
removed: false,
};

View File

@@ -32,7 +32,10 @@ import {
NativeFilterDivider,
ChartCustomizationsFormItem,
} from '../types';
import { buildNativeFilterTarget } from './buildTarget';
import {
buildNativeFilterTarget,
buildNativeFilterDefaultDataMask,
} from './buildTarget';
type FilterFormInput =
| NativeFiltersFormItem
@@ -113,7 +116,10 @@ function transformFormInput(
targets: [buildFilterTarget(formInputs)],
scope: formInputs.scope || defaultScope,
controlValues: formInputs.controlValues ?? {},
defaultDataMask: formInputs.defaultDataMask ?? getInitialDataMask(),
defaultDataMask: buildNativeFilterDefaultDataMask(
formInputs,
formInputs.defaultDataMask ?? getInitialDataMask(),
),
cascadeParentIds: formInputs.dependencies || [],
adhoc_filters: formInputs.adhoc_filters,
time_range: formInputs.time_range,

View File

@@ -56,6 +56,7 @@ export interface NativeFiltersFormItem {
type: typeof NativeFilterType.NativeFilter;
description: string;
datasourceType?: DatasourceType;
semantic_selection_version?: string;
}
export interface NativeFilterDivider {
id: string;
@@ -95,6 +96,7 @@ export interface ChartCustomizationsFormItem {
type: typeof NativeFilterType.NativeFilter;
description: string;
datasourceType?: DatasourceType;
semantic_selection_version?: string;
datasetInfo?: {
label: string | ReactNode;
value: number;

View File

@@ -24,6 +24,7 @@ import {
nativeFilterGate,
findTabsWithChartsInScope,
getFormData,
mergeExtraFormData,
} from './utils';
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
@@ -136,6 +137,32 @@ test('getFormData should include persisted time_grains for time grain filters',
expect((formData as any).time_grains).toEqual(['PT1H', 'P1D', 'P1W']);
});
test.each([false, true])(
'merging current and legacy member filters remains unversioned in either order (%s)',
reverse => {
const legacy = {
filters: [{ col: 'Orders.b', op: 'IN' as const, val: ['x'] }],
};
const current = {
...legacy,
semantic_selection_sources: [
{ datasource: '7__semantic_view', version: 'cube-member-id-v1' },
],
};
const merged = reverse
? mergeExtraFormData(current, legacy)
: mergeExtraFormData(legacy, current);
expect(merged.filters).toHaveLength(2);
expect(merged.semantic_selection_sources).toContainEqual({
datasource: '',
version: null,
});
expect(merged.semantic_selection_sources).toContainEqual({
datasource: '7__semantic_view',
version: 'cube-member-id-v1',
});
},
);
test('getFormData passes controlValues.displayFormat through to the filter plugin formData', () => {
const formData = getFormData({
dashboardId: 10,

View File

@@ -19,6 +19,7 @@
import { t } from '@apache-superset/core/translation';
import {
AdhocFilter,
getSemanticSelectionSources,
Behavior,
ChartCustomization,
DataMaskStateWithId,
@@ -49,6 +50,7 @@ const getDefaultRowLimit = (): number => {
export const getFormData = ({
datasetId,
datasourceType,
semantic_selection_version,
dependencies = {},
groupby,
defaultDataMask,
@@ -66,6 +68,7 @@ export const getFormData = ({
dashboardId: number;
datasetId?: number;
datasourceType?: DatasourceType;
semantic_selection_version?: string;
dependencies?: object;
groupby?: string;
adhoc_filters?: AdhocFilter[];
@@ -96,6 +99,7 @@ export const getFormData = ({
return {
...controlValues,
semantic_selection_version,
...timeGrainsFormData,
...otherProps,
adhoc_filters: adhoc_filters ?? [],
@@ -142,6 +146,15 @@ export function mergeExtraFormData(
mergedExtra[key] = newValue;
}
});
if (
originalExtra.semantic_selection_sources?.length ||
newExtra.semantic_selection_sources?.length
) {
mergedExtra.semantic_selection_sources = [
...getSemanticSelectionSources(originalExtra),
...getSemanticSelectionSources(newExtra),
];
}
return mergedExtra as ExtraFormData;
}

View File

@@ -26,7 +26,10 @@ import {
JsonObject,
PartialFilters,
ChartCustomization,
getSemanticSelectionSources,
QueryFormColumn,
QueryFormData,
SemanticSelectionSource,
} from '@superset-ui/core';
import {
ChartConfiguration,
@@ -34,6 +37,7 @@ import {
ActiveFilters,
} from 'src/dashboard/types';
import { getExtraFormData } from 'src/dashboard/components/nativeFilters/utils';
import { ChartCustomizationPlugins } from 'src/constants';
import { isChartCustomization } from 'src/dashboard/components/nativeFilters/FiltersConfigModal/utils';
import { isEqual } from 'lodash-es';
import { areObjectsEqual } from 'src/reduxUtils';
@@ -45,6 +49,55 @@ import {
import getEffectiveExtraFilters from './getEffectiveExtraFilters';
import { getAllActiveFilters } from '../activeAllDashboardFilters';
/** Return provenance only for customizations that contribute member selections. */
export function getCustomizationSelectionSources({
customization,
mask,
groupByApplied,
}: {
customization?: ChartCustomization;
mask?: DataMask;
groupByApplied: boolean;
}): SemanticSelectionSource[] {
if (
customization?.filterType === ChartCustomizationPlugins.DynamicGroupBy &&
!groupByApplied
)
return [];
const target = customization?.targets?.[0];
const memberOverrides = getSemanticSelectionSources({
...mask?.extraFormData,
semantic_selection_sources: undefined,
});
if (
(customization?.filterType === ChartCustomizationPlugins.TimeGrain ||
customization?.filterType ===
ChartCustomizationPlugins.DeckglLayerVisibility) &&
memberOverrides.length === 0
)
return [];
if (
customization?.filterType !== ChartCustomizationPlugins.DynamicGroupBy &&
memberOverrides.length === 0 &&
mask?.filterState?.value == null
)
return [];
const sources = getSemanticSelectionSources(mask?.extraFormData);
return [
{
datasource: target?.datasetId
? `${target.datasetId}__${target.datasourceType || 'table'}`
: '',
version: target?.semantic_selection_version ?? null,
},
...(sources.length
? sources
: mask?.filterState?.value != null
? [{ datasource: '', version: null }]
: []),
];
}
interface CachedFormData {
extra_form_data?: JsonObject;
extra_filters: {
@@ -79,6 +132,14 @@ const cachedFormdataByChart: Record<
dataMask: DataMask;
extraControls: Record<string, string | boolean | null>;
nativeFilters: PartialFilters;
chartSelectionInput: Partial<
Pick<
QueryFormData,
| 'datasource'
| 'semantic_selection_version'
| 'semantic_selection_sources'
>
>;
}
> = {};
@@ -278,30 +339,13 @@ function applyChartSpecificGroupBy(
return groupByFormData;
}
function processGroupByCustomizations(
function getMatchingGroupByCustomizations(
chartCustomizationItems: ChartCustomization[],
chart: ChartQueryPayload,
groupByState: Record<
string,
{ selectedValues: string[]; hasInteracted: boolean }
>,
): {
groupby?: string[];
x_axis?: string;
series?: string;
columns?: string[];
entity?: string;
source?: string;
target?: string;
groupbyColumns?: string[];
} {
if (!chartCustomizationItems || chartCustomizationItems.length === 0) {
return {};
}
): ChartCustomization[] {
const chartDataset = chart.form_data?.datasource;
if (!chartDataset) {
return {};
return [];
}
// ``form_data.datasource`` is encoded as ``<id>__<type>`` (e.g.
@@ -313,7 +357,7 @@ function processGroupByCustomizations(
const chartDatasetId = chartDatasetParts[0];
const chartDatasourceType = chartDatasetParts[1];
const matchingCustomizations = chartCustomizationItems.filter(item => {
return chartCustomizationItems.filter(item => {
if (item.removed) return false;
const targetDataset = item.targets?.[0]?.datasetId;
@@ -335,10 +379,39 @@ function processGroupByCustomizations(
return datasetMatches && datasourceTypeMatches && chartMatches;
});
}
type GroupByFormData = {
groupby?: string[];
x_axis?: string;
series?: string;
columns?: string[];
entity?: string;
source?: string;
target?: string;
groupbyColumns?: string[];
};
function processGroupByCustomizations(
chartCustomizationItems: ChartCustomization[],
chart: ChartQueryPayload,
groupByState: Record<
string,
{ selectedValues: string[]; hasInteracted: boolean }
>,
): { formData: GroupByFormData; appliedIds: Set<string> } {
if (!chartCustomizationItems || chartCustomizationItems.length === 0) {
return { formData: {}, appliedIds: new Set() };
}
const matchingCustomizations = getMatchingGroupByCustomizations(
chartCustomizationItems,
chart,
);
const chartType = chart.form_data?.viz_type;
if (isChartWithoutGroupBy(chartType) || chartType === 'chord') {
return {};
return { formData: {}, appliedIds: new Set() };
}
const existingColumns = buildExistingColumnsSet(chart);
@@ -347,6 +420,7 @@ function processGroupByCustomizations(
const groupByColumns: string[] = [];
let heatmapColumnAdded = false;
const contributions = new Map<string, string[]>();
matchingCustomizations.forEach(item => {
if (!item.targets?.[0]) return;
@@ -375,6 +449,7 @@ function processGroupByCustomizations(
return;
}
const previousLength = groupByColumns.length;
if (isSingleColumnDimensionChart(chartType)) {
if (!heatmapColumnAdded && nonConflictingColumns.length > 0) {
const firstColumn = nonConflictingColumns[0];
@@ -393,16 +468,27 @@ function processGroupByCustomizations(
}
});
}
contributions.set(item.id, groupByColumns.slice(previousLength));
});
const groupByFormData = applyChartSpecificGroupBy(
const groupByFormData: GroupByFormData = applyChartSpecificGroupBy(
chartType,
groupByColumns,
existingGroupBy,
xAxisColumn,
);
return groupByFormData;
const appliedColumns = new Set<string>(Object.values(groupByFormData).flat());
return {
formData: groupByFormData,
appliedIds: new Set(
[...contributions]
.filter(([, columns]) =>
columns.some(column => appliedColumns.has(column)),
)
.map(([id]) => id),
),
};
}
// this function merge chart's formData with dashboard filters value,
@@ -427,6 +513,11 @@ export default function getFormDataWithExtraFilters({
chartCustomization,
activeFilters: passedActiveFilters,
}: GetFormDataWithExtraFiltersArguments) {
const chartSelectionInput = {
datasource: chart.form_data?.datasource,
semantic_selection_version: chart.form_data?.semantic_selection_version,
semantic_selection_sources: chart.form_data?.semantic_selection_sources,
};
const cachedFormData = cachedFormdataByChart[sliceId];
const dataMaskEqual = areObjectsEqual(cachedFormData?.dataMask, dataMask, {
ignoreUndefined: true,
@@ -438,6 +529,7 @@ export default function getFormDataWithExtraFilters({
);
if (
cachedFiltersByChart[sliceId] === filters &&
isEqual(cachedFormData?.chartSelectionInput, chartSelectionInput) &&
areObjectsEqual(cachedFormData?.own_color_scheme, ownColorScheme) &&
areObjectsEqual(cachedFormData?.color_scheme, colorScheme) &&
areObjectsEqual(cachedFormData?.color_namespace, colorNamespace, {
@@ -557,11 +649,8 @@ export default function getFormDataWithExtraFilters({
item => item.filterType === 'chart_customization_dynamic_groupby',
) || [];
const groupByFormData = processGroupByCustomizations(
groupByCustomizations,
chart,
groupByState,
);
const { formData: groupByFormData, appliedIds: appliedGroupByIds } =
processGroupByCustomizations(groupByCustomizations, chart, groupByState);
const customizationExtraFormData =
customizationIds.length > 0
@@ -584,12 +673,28 @@ export default function getFormDataWithExtraFilters({
...groupByFormData,
...customizationExtraFormData,
...(chartCustomization && { chart_customization: chartCustomization }),
// Customizations can rewrite member fields outside extra_form_data.
...(chart.form_data?.semantic_selection_version && {
semantic_selection_sources: [
...(chart.form_data.semantic_selection_sources ?? []),
...customizationIds.flatMap(id =>
getCustomizationSelectionSources({
customization: chartCustomizationItems?.find(
item => item.id === id,
),
mask: dataMask[id],
groupByApplied: appliedGroupByIds.has(id),
}),
),
],
}),
...(layerFilterScope && { layer_filter_scope: layerFilterScope }),
};
cachedFiltersByChart[sliceId] = filters;
cachedFormdataByChart[sliceId] = {
...formData,
chartSelectionInput,
dataMask,
extraControls,
nativeFilters,

View File

@@ -18,9 +18,17 @@
*/
import getFormDataWithExtraFilters, {
CachedFormDataWithExtraControls,
getCustomizationSelectionSources,
GetFormDataWithExtraFiltersArguments,
} from 'src/dashboard/util/charts/getFormDataWithExtraFilters';
import { ChartCustomizationType } from '@superset-ui/core';
import {
ChartCustomizationType,
DataMask,
SemanticSelectionSource,
DatasourceType,
buildQueryObject,
QueryFormData,
} from '@superset-ui/core';
import { sliceId as chartId } from 'spec/fixtures/mockChartQueries';
type ChartCustomizationItem = NonNullable<
@@ -44,6 +52,125 @@ function createChartCustomization(
};
}
const semanticCustomization = createChartCustomization({
targets: [
{
datasetId: 7,
datasourceType: DatasourceType.SemanticView,
semantic_selection_version: 'cube-member-id-v1',
},
],
});
const semanticTargetSource = {
datasource: '7__semantic_view',
version: 'cube-member-id-v1',
};
const unknownSelectionSource = { datasource: '', version: null };
const selectedMask: DataMask = { filterState: { value: ['country'] } };
const certifiedMask: DataMask = {
...selectedMask,
extraFormData: { semantic_selection_sources: [semanticTargetSource] },
};
test.each<{
name: string;
customization?: ChartCustomizationItem;
mask?: DataMask;
groupByApplied: boolean;
expected: SemanticSelectionSource[];
}>([
{
name: 'unapplied group-by',
customization: semanticCustomization,
mask: certifiedMask,
groupByApplied: false,
expected: [],
},
{
name: 'applied certified group-by',
customization: semanticCustomization,
mask: certifiedMask,
groupByApplied: true,
expected: [semanticTargetSource, semanticTargetSource],
},
{
name: 'uncertified selected group-by',
customization: semanticCustomization,
mask: selectedMask,
groupByApplied: true,
expected: [semanticTargetSource, unknownSelectionSource],
},
...[
'chart_customization_timegrain',
'chart_customization_deckgl_layer_visibility',
].map(filterType => ({
name: `presentation-only ${filterType}`,
customization: { ...semanticCustomization, filterType },
mask: selectedMask,
groupByApplied: false,
expected: [],
})),
{
name: 'presentation control with member overrides',
customization: {
...semanticCustomization,
filterType: 'chart_customization_timegrain',
},
mask: { extraFormData: { granularity_sqla: 'country' } },
groupByApplied: false,
expected: [semanticTargetSource, unknownSelectionSource],
},
{
name: 'idle ordinary control',
customization: { ...semanticCustomization, filterType: 'ordinary' },
mask: {},
groupByApplied: false,
expected: [],
},
{
name: 'selected ordinary control without provenance',
customization: { ...semanticCustomization, filterType: 'ordinary' },
mask: selectedMask,
groupByApplied: false,
expected: [semanticTargetSource, unknownSelectionSource],
},
{
name: 'legacy table target',
customization: createChartCustomization(),
mask: selectedMask,
groupByApplied: true,
expected: [
{ datasource: '3__table', version: null },
unknownSelectionSource,
],
},
{
name: 'missing customization and idle mask',
groupByApplied: false,
expected: [],
},
{
name: 'missing target identity',
customization: createChartCustomization({ targets: [] }),
mask: selectedMask,
groupByApplied: true,
expected: [unknownSelectionSource, unknownSelectionSource],
},
{
name: 'applied group-by without selected value',
customization: semanticCustomization,
groupByApplied: true,
expected: [semanticTargetSource],
},
])(
'customization provenance: $name',
({ customization, mask, groupByApplied, expected }) => {
expect(
getCustomizationSelectionSources({ customization, mask, groupByApplied }),
).toEqual(expected);
},
);
const expectGroupBy = (
result: CachedFormDataWithExtraControls,
expected: unknown,
@@ -591,3 +718,286 @@ test('chart customization does not match across datasource ID spaces', () => {
const result = getFormDataWithExtraFilters(args);
expectGroupBy(result, ['original_column']);
});
test.each([false, true])(
'dynamic group-by card mask stays unsupported even with a versioned target (current=%s)',
current => {
const customizationId = 'CHART_CUSTOMIZATION-identity';
const result = getFormDataWithExtraFilters({
...mockArgs,
filters: {},
chart: {
...mockChart,
form_data: {
...mockChart.form_data,
viz_type: 'table',
datasource: '3__semantic_view',
groupby: ['original_column'],
semantic_selection_version: 'cube-member-id-v1',
},
},
dataMask: {
[customizationId]: {
id: customizationId,
filterState: { value: ['Orders.status'] },
// Match GroupByFilterCard's emission: it never certifies sources.
extraFormData: {
custom_form_data: { groupby: ['Orders.status'] },
},
},
},
chartCustomizationItems: [
createChartCustomization({
id: customizationId,
targets: [
{
datasetId: 3,
datasourceType: DatasourceType.SemanticView,
semantic_selection_version: current
? 'cube-member-id-v1'
: undefined,
},
],
}),
],
});
expectGroupBy(result, ['Orders.status']);
expect(
buildQueryObject({
...result,
datasource: '3__semantic_view',
viz_type: 'table',
} as QueryFormData).extras?.semantic_selection_version,
).toEqual('unverified-external-selections');
},
);
test('a foreign dynamic-groupby customization that does not apply cannot invalidate Cube selections', () => {
const customizationId = 'CHART_CUSTOMIZATION-foreign';
const result = getFormDataWithExtraFilters({
...mockArgs,
filters: {},
chart: {
...mockChart,
form_data: {
...mockChart.form_data,
datasource: '3__semantic_view',
viz_type: 'table',
groupby: ['Orders.status'],
semantic_selection_version: 'cube-member-id-v1',
},
},
dataMask: {
[customizationId]: {
id: customizationId,
filterState: { value: ['status'] },
extraFormData: {},
},
},
chartCustomizationItems: [
createChartCustomization({
id: customizationId,
targets: [{ datasetId: 5, datasourceType: DatasourceType.Table }],
}),
],
});
expectGroupBy(result, ['Orders.status']);
expect(
buildQueryObject({
...result,
datasource: '3__semantic_view',
viz_type: 'table',
} as QueryFormData).extras?.semantic_selection_version,
).toBe('cube-member-id-v1');
});
test.each([
'chart_customization_timegrain',
'chart_customization_deckgl_layer_visibility',
])(
'non-member customization %s does not invalidate Cube identity',
filterType => {
const customizationId = 'CHART_CUSTOMIZATION-presentation';
const result = getFormDataWithExtraFilters({
...mockArgs,
filters: {},
chart: {
...mockChart,
form_data: {
...mockChart.form_data,
datasource: '3__semantic_view',
viz_type: 'table',
semantic_selection_version: 'cube-member-id-v1',
},
},
dataMask: {
[customizationId]: {
id: customizationId,
filterState: { value: ['P1D'] },
extraFormData:
filterType === 'chart_customization_timegrain'
? { time_grain_sqla: 'P1D' }
: { visible_deckgl_layers: [1, 2] },
},
},
chartCustomizationItems: [
createChartCustomization({
id: customizationId,
filterType,
targets: [],
}),
],
});
expect(
buildQueryObject({
...result,
datasource: '3__semantic_view',
viz_type: 'table',
} as QueryFormData).extras?.semantic_selection_version,
).toBe('cube-member-id-v1');
},
);
test.each([
{
datasetId: 3,
datasourceType: DatasourceType.SemanticView,
value: undefined,
},
{ datasetId: 5, datasourceType: DatasourceType.Table, value: undefined },
{ datasetId: 3, datasourceType: DatasourceType.SemanticView, value: null },
{ datasetId: 5, datasourceType: DatasourceType.Table, value: null },
])(
'idle time-column customization does not invalidate Cube identity: %j',
({ value, ...target }) => {
const customizationId = 'CHART_CUSTOMIZATION-idle-time-column';
const result = getFormDataWithExtraFilters({
...mockArgs,
filters: {},
chart: {
...mockChart,
form_data: {
...mockChart.form_data,
datasource: '3__semantic_view',
semantic_selection_version: 'cube-member-id-v1',
},
},
dataMask: {
[customizationId]: {
id: customizationId,
filterState: { value },
extraFormData: {},
},
},
chartCustomizationItems: [
createChartCustomization({
id: customizationId,
filterType: 'chart_customization_timecolumn',
targets: [target],
}),
],
});
expect(
buildQueryObject({
...result,
datasource: '3__semantic_view',
viz_type: 'table',
} as QueryFormData).extras?.semantic_selection_version,
).toBe('cube-member-id-v1');
},
);
test.each([[42], ['Orders.count'], ['']])(
'a non-contributing customization does not add provenance: %j',
ignoredValue => {
const currentId = 'CHART_CUSTOMIZATION-current';
const ignoredId = 'CHART_CUSTOMIZATION-ignored';
const result = getFormDataWithExtraFilters({
...mockArgs,
filters: {},
chart: {
...mockChart,
form_data: {
...mockChart.form_data,
datasource: '3__semantic_view',
viz_type: 'table',
metrics: ['Orders.count'],
semantic_selection_version: 'cube-member-id-v1',
},
},
dataMask: {
[currentId]: {
id: currentId,
filterState: { value: ['Orders.status'] },
},
[ignoredId]: { id: ignoredId, filterState: { value: [ignoredValue] } },
},
chartCustomizationItems: [
createChartCustomization({
id: currentId,
targets: [
{
datasetId: 3,
datasourceType: DatasourceType.SemanticView,
semantic_selection_version: 'cube-member-id-v1',
},
],
}),
createChartCustomization({
id: ignoredId,
targets: [
{ datasetId: 3, datasourceType: DatasourceType.SemanticView },
],
}),
],
});
expectGroupBy(result, ['Orders.status']);
expect(result).toMatchObject({
semantic_selection_sources: [
{ datasource: '3__semantic_view', version: 'cube-member-id-v1' },
{ datasource: '', version: null },
],
});
// Actual dynamic-groupby masks still lack identity evidence.
expect(
buildQueryObject({
...result,
datasource: '3__semantic_view',
viz_type: 'table',
} as QueryFormData).extras?.semantic_selection_version,
).toBe('unverified-external-selections');
},
);
test.each([
{ semantic_selection_version: 'next-version' },
{
semantic_selection_sources: [
{ datasource: 'other__semantic_view', version: null },
],
},
{ datasource: '8__semantic_view' },
])('cache invalidates when chart selection provenance changes: %p', change => {
const args = {
...mockArgs,
filters: {},
chart: {
...mockChart,
form_data: {
...mockChart.form_data,
datasource: '7__semantic_view',
semantic_selection_version: 'cube-member-id-v1',
semantic_selection_sources: [],
},
},
};
const first = getFormDataWithExtraFilters(args);
const cached = getFormDataWithExtraFilters(args);
expect(getFormDataWithExtraFilters(args)).toBe(cached);
const changed = getFormDataWithExtraFilters({
...args,
chart: { ...args.chart, form_data: { ...args.chart.form_data, ...change } },
});
expect(changed).not.toBe(first);
expect(changed).toEqual(expect.objectContaining(change));
});

View File

@@ -30,6 +30,7 @@ import {
type NativeFilterTarget,
NativeFilterType,
ChartCustomizationType,
DatasourceType,
} from '@superset-ui/core';
import { getSelectExtraFormData } from 'src/filters/utils';
import { HYDRATE_DASHBOARD } from 'src/dashboard/actions/hydrate';
@@ -217,6 +218,63 @@ test('HYDRATE_DASHBOARD handles chart_customization_config that is entirely null
expect(customizationKeys).toHaveLength(0);
});
test('restored native and customization values cannot inherit a versioned default marker', () => {
const target = {
datasetId: 7,
datasourceType: DatasourceType.SemanticView,
column: { name: 'Orders.status' },
semantic_selection_version: 'cube-member-id-v1',
};
const defaultDataMask = {
filterState: { value: ['current'] },
extraFormData: {
semantic_selection_sources: [
{ datasource: '7__semantic_view', version: 'cube-member-id-v1' },
],
},
};
const filter: Filter = {
...createFilter('NATIVE_FILTER-identity'),
targets: [target],
defaultDataMask,
};
const customization: ChartCustomization = {
id: 'CHART_CUSTOMIZATION-identity',
type: ChartCustomizationType.ChartCustomization,
name: 'Group',
filterType: 'chart_customization_dynamic_groupby',
targets: [target],
defaultDataMask,
controlValues: {},
scope: { rootPath: [], excluded: [] },
};
const loaded: DataMaskStateWithId = {
[filter.id]: { id: filter.id, filterState: { value: ['old title'] } },
[customization.id]: {
id: customization.id,
filterState: { value: ['old title'] },
},
};
const result = reducer({}, {
type: HYDRATE_DASHBOARD,
data: {
dataMask: loaded,
dashboardInfo: {
metadata: {
native_filter_configuration: [filter],
chart_customization_config: [customization],
},
},
},
} as Parameters<typeof reducer>[1]);
for (const id of [filter.id, customization.id]) {
expect(result[id].filterState?.value).toEqual(['old title']);
expect(
result[id].extraFormData?.semantic_selection_sources,
).toBeUndefined();
}
});
test.each<[null | [], boolean]>([
[null, false],
[[], false],

View File

@@ -77,6 +77,16 @@ export interface HydrateDataMaskAction {
};
}
function getRestoredSelectionEvidence(
mask: DataMask | undefined,
version: string | undefined,
): Partial<DataMask> {
// A restored value must not inherit the default's identity evidence.
return version && mask?.filterState && !mask.extraFormData
? { extraFormData: {} }
: {};
}
function isChartCustomizationItem(item: unknown): item is ChartCustomization {
return (
typeof item === 'object' &&
@@ -162,6 +172,10 @@ function fillNativeFilters(
...getInitialDataMask(filter.id), // take initial data
...filter.defaultDataMask, // if something new came from BE - take it
...loaded,
...getRestoredSelectionEvidence(
loaded,
filter.targets?.[0]?.semantic_selection_version,
),
...(shouldRestoreDefault
? {
filterState: filter.defaultDataMask?.filterState,
@@ -325,6 +339,10 @@ const dataMaskReducer = produce(
...getInitialDataMask(customizationFilterId),
...item.defaultDataMask,
...dataMask[customizationFilterId],
...getRestoredSelectionEvidence(
dataMask[customizationFilterId],
item.targets?.[0]?.semantic_selection_version,
),
};
if (

View File

@@ -18,6 +18,7 @@
*/
/* eslint camelcase: 0 */
import rison from 'rison';
import { clearDataMask } from 'src/dataMask/actions';
import { Dataset } from '@superset-ui/chart-controls';
import { t } from '@apache-superset/core/translation';
import { SupersetClient, QueryFormData } from '@superset-ui/core';
@@ -29,6 +30,14 @@ import {
import { Slice } from 'src/types/Chart';
import { CompatibilityResult, SaveActionType } from 'src/explore/types';
export const RESET_SEMANTIC_SELECTIONS = 'RESET_SEMANTIC_SELECTIONS';
export function resetSemanticSelections(sliceId?: number) {
return (dispatch: Dispatch) => {
if (sliceId !== undefined) dispatch(clearDataMask(sliceId));
dispatch({ type: RESET_SEMANTIC_SELECTIONS });
};
}
export const UPDATE_FORM_DATA_BY_DATASOURCE = 'UPDATE_FORM_DATA_BY_DATASOURCE';
export function updateFormDataByDatasource(
prevDatasource: Dataset,
@@ -273,6 +282,7 @@ export function syncDatasourceMetadata(datasource: Dataset) {
}
export const exploreActions = {
resetSemanticSelections,
...toastActions,
fetchDatasourcesStarted,
fetchDatasourcesSucceeded,

View File

@@ -131,6 +131,66 @@ describe('ControlPanelsContainer', () => {
} as ControlPanelsContainerProps;
}
test.each([undefined, 21])(
'new and saved semantic selections expose explicit field initialization (slice=%s)',
async sliceId => {
const registry = getChartControlPanelRegistry();
const previous = registry.get('line');
registry.registerValue('line', defaultTableConfig);
try {
mockIsFeatureEnabled.mockImplementation(
featureFlag => featureFlag === FeatureFlag.Matrixify,
);
const props = getDefaultProps();
const resetSemanticSelections = jest.fn();
props.actions = { setControlValue: jest.fn(), resetSemanticSelections };
props.exploreState = {
...defaultState,
datasource: { semantic_selection_version: 'cube-member-id-v1' },
} as ControlPanelsContainerProps['exploreState'];
props.form_data = {
...props.form_data,
datasource: '7__semantic_view',
slice_id: sliceId,
viz_type: 'line',
matrixify_enable: true,
matrixify_mode_rows: 'metrics',
semantic_selection_version: undefined,
};
render(<ControlPanelsContainer {...props} />, { useRedux: true });
expect(screen.queryByText('Query')).not.toBeInTheDocument();
expect(
screen.queryByRole('tab', { name: /matrixify/i }),
).not.toBeInTheDocument();
await userEvent.click(
screen.getByRole('button', { name: 'Start field selection' }),
);
expect(resetSemanticSelections).toHaveBeenCalledWith(sliceId);
} finally {
if (previous) registry.registerValue('line', previous);
else registry.remove('line');
}
},
);
test('current semantic selections render query controls', async () => {
const props = getDefaultProps();
props.exploreState = {
...defaultState,
datasource: { semantic_selection_version: 'cube-member-id-v1' },
} as ControlPanelsContainerProps['exploreState'];
props.form_data = {
...props.form_data,
datasource: '7__semantic_view',
semantic_selection_version: 'cube-member-id-v1',
};
render(<ControlPanelsContainer {...props} />, { useRedux: true });
expect(screen.getByText('Query')).toBeInTheDocument();
expect(
screen.queryByRole('button', { name: 'Start field selection' }),
).not.toBeInTheDocument();
});
test('renders ControlPanelSections', async () => {
render(<ControlPanelsContainer {...getDefaultProps()} />, {
useRedux: true,
@@ -643,7 +703,7 @@ describe('ControlPanelsContainer', () => {
type: 'SelectControl' as const,
value: ComparisonType.Values,
};
props.actions = { setControlValue };
props.actions = { setControlValue, resetSemanticSelections: jest.fn() };
props.controls = {
...props.controls,
header_groups: {

View File

@@ -104,8 +104,7 @@ const MATRIXIFY_INCOMPATIBLE_CHARTS = new Set([
export type ControlPanelsContainerProps = {
exploreState: ExplorePageState['explore'];
// Only setControlValue is used from actions in this component
actions: Pick<ExploreActions, 'setControlValue'>;
actions: Pick<ExploreActions, 'setControlValue' | 'resetSemanticSelections'>;
datasource_type: DatasourceType;
chart: ChartState;
controls: Record<string, ControlState>;
@@ -912,8 +911,14 @@ export const ControlPanelsContainer = (props: ControlPanelsContainerProps) => {
props.errorMessage,
]);
const requiresSemanticReselection = Boolean(
props.exploreState.datasource?.semantic_selection_version &&
form_data.semantic_selection_version !==
props.exploreState.datasource.semantic_selection_version,
);
const showCustomizeTab = customizeSections.length > 0;
const showMatrixifyTab =
!requiresSemanticReselection &&
isFeatureEnabled(FeatureFlag.Matrixify) &&
!MATRIXIFY_INCOMPATIBLE_CHARTS.has(form_data.viz_type as VizType);
@@ -927,10 +932,12 @@ export const ControlPanelsContainer = (props: ControlPanelsContainerProps) => {
// Auto-switch to Matrixify tab when it's enabled
useEffect(() => {
if (showMatrixifyTab && matrixifyIsEnabled) {
if (requiresSemanticReselection) {
setActiveTabKey(TABS_KEYS.DATA);
} else if (showMatrixifyTab && matrixifyIsEnabled) {
setActiveTabKey(TABS_KEYS.MATRIXIFY);
}
}, [showMatrixifyTab, matrixifyIsEnabled]);
}, [requiresSemanticReselection, showMatrixifyTab, matrixifyIsEnabled]);
// Check if matrixify sections have validation errors
const matrixifyHasErrors = useMemo(() => {
@@ -1027,14 +1034,29 @@ export const ControlPanelsContainer = (props: ControlPanelsContainerProps) => {
label: dataTabTitle,
children: (
<>
{requiresSemanticReselection && (
<ExploreAlert
title={t('Choose current semantic fields')}
bodyText={t(
'Start field selection using current member IDs. This clears any existing query and formatting settings. Saved display titles cannot be safely mapped to member IDs. The saved chart is unchanged until you save.',
)}
primaryButtonText={t('Start field selection')}
primaryButtonAction={() =>
actions.resetSemanticSelections(form_data.slice_id)
}
type="warning"
/>
)}
{showDatasourceAlert && <DatasourceAlert />}
<Collapse
defaultActiveKey={expandedQuerySections}
expandIconPosition="end"
ghost
bordered
items={querySections.map(renderControlPanelSection)}
/>
{!requiresSemanticReselection && (
<Collapse
defaultActiveKey={expandedQuerySections}
expandIconPosition="end"
ghost
bordered
items={querySections.map(renderControlPanelSection)}
/>
)}
</>
),
},

View File

@@ -1390,3 +1390,20 @@ test('Filter subject lists and commits an expression-less Cube dimension', async
);
});
});
test('versioned suggestions are unavailable without a failure and allow manual input', async () => {
const { onChange } = setupWithFilterValuesResponse({
result: [],
suggestions_status: 'unavailable_versioned_view',
});
const comparator = await openComparator();
expect(
await screen.findByText(
'Suggestions are unavailable for this view. Enter a value manually.',
),
).toBeInTheDocument();
expect(screen.queryByText(SUGGESTIONS_UNAVAILABLE)).not.toBeInTheDocument();
await userEvent.type(comparator, 'manual');
await userEvent.click(await screen.findByTitle('manual'));
expect(onChange).toHaveBeenCalled();
});

View File

@@ -403,6 +403,7 @@ const AdhocFilterEditPopoverSimpleTabContent: FC<Props> = props => {
const [loadedOptionCount, setLoadedOptionCount] = useState(0);
const [optionsTruncated, setOptionsTruncated] = useState(false);
const [suggestionsUnavailable, setSuggestionsUnavailable] = useState(false);
const [suggestionsDisabled, setSuggestionsDisabled] = useState(false);
// Identity of the newest suggestions request. A slow response that loses
// the race -- a failing fetch resolving after a newer search succeeded, or
// after the column changed -- must not stamp its outcome over the current
@@ -547,15 +548,17 @@ const AdhocFilterEditPopoverSimpleTabContent: FC<Props> = props => {
// months-long 500 on every semantic view went unreported. Likewise a capped
// list reads as the whole set, so an absent value looks like a value that
// does not exist. Each note is only shown when it applies.
helperText: suggestionsUnavailable
? t('Suggestions could not be loaded. You can still type a value.')
: optionsTruncated
? t(
'Only the first %s values are listed. Type to search all of them, ' +
'or enter a value that is not listed.',
loadedOptionCount,
)
: undefined,
helperText: suggestionsDisabled
? t('Suggestions are unavailable for this view. Enter a value manually.')
: suggestionsUnavailable
? t('Suggestions could not be loaded. You can still type a value.')
: optionsTruncated
? t(
'Only the first %s values are listed. Type to search all of them, ' +
'or enter a value that is not listed.',
loadedOptionCount,
)
: undefined,
mode:
operatorId && MULTI_OPERATORS.has(operatorId as Operators)
? ('multiple' as const)
@@ -626,6 +629,11 @@ const AdhocFilterEditPopoverSimpleTabContent: FC<Props> = props => {
`/api/v1/datasource/${datasourceType}/${datasourceId}` +
`/column/${encodeURIComponent(col)}/values/${query ? `?${query}` : ''}`,
});
if (isCurrent()) {
setSuggestionsDisabled(
json.suggestions_status === 'unavailable_versioned_view',
);
}
const data = json.result.map((suggestion: unknown) => {
// Complex column values arrive as JS arrays or objects: whole arrays
// for MULTI_VALUE columns (e.g. [5, 6, 7]) and Map/Tuple objects for
@@ -694,6 +702,7 @@ const AdhocFilterEditPopoverSimpleTabContent: FC<Props> = props => {
// previous column must not resurface the note after this reset.
comparatorRequestRef.current += 1;
setSuggestionsUnavailable(false);
setSuggestionsDisabled(false);
}, [subjectString, arrayElements]);
useEffect(() => {

View File

@@ -16,9 +16,10 @@
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen, waitFor } from 'spec/helpers/testing-library';
import { act, render, screen, waitFor } from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import { SupersetClient } from '@superset-ui/core';
import { Constants } from '@superset-ui/core/components';
import MatrixifyDimensionControl, {
MatrixifyDimensionControlValue,
} from './MatrixifyDimensionControl';
@@ -549,3 +550,139 @@ test('should preserve values when other props change but mode stays the same', a
topNValues: [],
});
});
test('versioned suggestions preserve selections and allow manual entry', async () => {
(SupersetClient.get as jest.Mock).mockResolvedValue({
json: { result: [], suggestions_status: 'unavailable_versioned_view' },
});
const onChange = jest.fn();
render(
<MatrixifyDimensionControl
{...defaultProps}
onChange={onChange}
value={{ dimension: 'country', values: ['saved'] }}
selectionMode="members"
/>,
);
expect(
await screen.findByText(
'Suggestions are unavailable. Enter values manually.',
),
).toBeInTheDocument();
expect(onChange).not.toHaveBeenCalled();
await userEvent.type(
screen.getByRole('combobox', { name: 'Select dimension values' }),
'manual',
);
await userEvent.click(await screen.findByTitle('manual'));
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({ values: ['saved', 'manual'] }),
);
});
test.each(['a_to_z', 'z_to_a'] as const)(
'versioned suggestions keep the members picker hidden in all mode with %s sort',
async allSortBy => {
(SupersetClient.get as jest.Mock).mockResolvedValue({
json: { result: [], suggestions_status: 'unavailable_versioned_view' },
});
const onChange = jest.fn();
const value: MatrixifyDimensionControlValue = {
dimension: 'country',
values: ['saved'],
totalValueCount: 10,
topNValues: [{ value: 'saved', metricValue: 5 }],
};
await act(async () => {
render(
<MatrixifyDimensionControl
{...defaultProps}
onChange={onChange}
value={value}
selectionMode="all"
allSortBy={allSortBy}
/>,
);
});
expect(SupersetClient.get).toHaveBeenCalled();
expect(
screen.queryByRole('combobox', { name: 'Select dimension values' }),
).not.toBeInTheDocument();
expect(
screen.queryByText('Suggestions are unavailable. Enter values manually.'),
).not.toBeInTheDocument();
expect(
screen.getByText(
'Values cannot be refreshed in All mode for this semantic view. Saved values are preserved. Switch to Members to enter values manually.',
),
).toBeInTheDocument();
expect(onChange).not.toHaveBeenCalled();
},
);
test('available suggestions do not allow manual member values', async () => {
jest.useFakeTimers({ advanceTimers: true });
try {
(SupersetClient.get as jest.Mock).mockResolvedValue({
json: { result: ['USA', 'Canada'] },
});
const onChange = jest.fn();
render(
<MatrixifyDimensionControl
{...defaultProps}
onChange={onChange}
value={{ dimension: 'country', values: ['USA'] }}
selectionMode="members"
/>,
);
await waitFor(() =>
expect(onChange).toHaveBeenCalledWith({
dimension: 'country',
values: ['USA'],
totalValueCount: 2,
}),
);
onChange.mockClear();
const input = screen.getByRole('combobox', {
name: 'Select dimension values',
});
await userEvent.type(input, 'not-a-suggestion');
await act(async () => {
jest.advanceTimersByTime(Constants.FAST_DEBOUNCE + 50);
});
expect(await screen.findByText('No results')).toBeInTheDocument();
expect(screen.queryByTitle('not-a-suggestion')).not.toBeInTheDocument();
await userEvent.keyboard('{Enter}');
expect(onChange).not.toHaveBeenCalled();
} finally {
jest.useRealTimers();
}
});
test.each(['a_to_z', 'z_to_a'] as const)(
'fresh all-mode selections explain unavailable suggestions with %s sort',
async allSortBy => {
(SupersetClient.get as jest.Mock).mockResolvedValue({
json: { result: [], suggestions_status: 'unavailable_versioned_view' },
});
const onChange = jest.fn();
render(
<MatrixifyDimensionControl
{...defaultProps}
onChange={onChange}
value={{ dimension: 'country', values: [] }}
selectionMode="all"
allSortBy={allSortBy}
/>,
);
expect(
await screen.findByText(
'Values cannot be refreshed in All mode for this semantic view. Saved values are preserved. Switch to Members to enter values manually.',
),
).toBeInTheDocument();
expect(
screen.queryByRole('combobox', { name: 'Select dimension values' }),
).not.toBeInTheDocument();
expect(onChange).not.toHaveBeenCalled();
},
);

View File

@@ -78,6 +78,7 @@ export default function MatrixifyDimensionControl(
Array<{ label: string; value: any }>
>([]);
const [loadingValues, setLoadingValues] = useState(false);
const [suggestionsDisabled, setSuggestionsDisabled] = useState(false);
const [topNError, setTopNError] = useState<string | null>(null);
const [dimensionHovered, setDimensionHovered] = useState(false);
const [valuesHovered, setValuesHovered] = useState(false);
@@ -115,6 +116,7 @@ export default function MatrixifyDimensionControl(
// Load dimension values when dimension changes (members mode, or all mode with A-Z/Z-A sort)
const isAllWithMetric = selectionMode === 'all' && allSortBy === 'metric';
useEffect(() => {
setSuggestionsDisabled(false);
if (
!value?.dimension ||
!datasource ||
@@ -149,6 +151,12 @@ export default function MatrixifyDimensionControl(
signal,
endpoint,
});
if (signal.aborted) return;
if (json.suggestions_status === 'unavailable_versioned_view') {
setSuggestionsDisabled(true);
setValueOptions([]);
return;
}
let values = json.result || [];
// Sort alphabetically for 'all' mode
@@ -349,17 +357,33 @@ export default function MatrixifyDimensionControl(
/>
}
mode="multiple"
allowNewOptions={suggestionsDisabled}
onChange={handleValuesChange}
options={valueOptions}
placeholder={t('Select values')}
placeholder={
suggestionsDisabled ? t('Enter values') : t('Select values')
}
loading={loadingValues}
allowClear
showSearch
notFoundContent={t('No results')}
/>
{suggestionsDisabled && (
<span>
{t('Suggestions are unavailable. Enter values manually.')}
</span>
)}
</div>
)}
{value?.dimension && selectionMode === 'all' && suggestionsDisabled && (
<span>
{t(
'Values cannot be refreshed in All mode for this semantic view. Saved values are preserved. Switch to Members to enter values manually.',
)}
</span>
)}
{value?.dimension && selectionMode === 'topn' && topNError && (
<div css={theme => ({ color: theme.colorError })}>
{t('Error: %s', topNError)}

View File

@@ -27,6 +27,13 @@ export const datasourceAndVizType: ControlPanelSectionConfig = {
['datasource'],
['viz_type'],
[
{
name: 'semantic_selection_version',
config: {
type: 'HiddenControl',
hidden: true,
},
},
{
name: 'slice_id',
config: {

View File

@@ -17,9 +17,20 @@
* under the License.
*/
import { QueryFormData } from '@superset-ui/core';
import { sections, CustomControlItem } from '@superset-ui/chart-controls';
import { getControlStateFromControlConfig } from 'src/explore/controlUtils';
import {
DatasourceType,
QueryFormData,
getChartControlPanelRegistry,
} from '@superset-ui/core';
import {
sections,
CustomControlItem,
Dataset,
} from '@superset-ui/chart-controls';
import {
getControlStateFromControlConfig,
getFormDataFromControls,
} from 'src/explore/controlUtils';
import exploreReducer, { ExploreState } from './exploreReducer';
import {
setCompatibility,
@@ -168,3 +179,52 @@ test('SET_FIELD_VALUE clears the custom-shift date error when time_compare leave
);
expect(afterSwitch.controls.start_date_offset.validationErrors).toEqual([]);
});
test('explicit semantic reset removes stale fields and stash and persists only new generation', () => {
const datasource = {
id: 7,
uid: 'cube:Orders',
type: DatasourceType.SemanticView,
semantic_selection_version: 'cube-member-id-v1',
columns: [],
metrics: [],
verbose_map: {},
column_formats: {},
main_dttm_col: '',
datasource_name: 'Orders',
description: null,
} as Dataset;
getChartControlPanelRegistry().registerValue('identity-test', {
// Exercise Explore's default section, without injecting the shared section.
controlPanelSections: [],
});
const initial: ExploreState = {
datasource,
controls: {},
form_data: {
datasource: '7__semantic_view',
viz_type: 'identity-test',
slice_id: 12,
metrics: ['Orders.b'],
column_config: { 'Orders.b': {} },
},
hiddenFormData: { metrics: ['Orders.b'] },
};
const refreshed = exploreReducer(initial, {
type: 'SYNC_DATASOURCE_METADATA',
datasource,
});
expect(refreshed.form_data.semantic_selection_version).toBeUndefined();
const reset = exploreReducer(initial, { type: 'RESET_SEMANTIC_SELECTIONS' });
expect(reset.form_data).toEqual({
datasource: '7__semantic_view',
viz_type: 'identity-test',
slice_id: 12,
semantic_selection_version: 'cube-member-id-v1',
});
expect(reset.hiddenFormData).toEqual({});
expect(
getFormDataFromControls(reset.controls).semantic_selection_version,
).toBe('cube-member-id-v1');
getChartControlPanelRegistry().remove('identity-test');
});

View File

@@ -204,7 +204,8 @@ type ExploreAction =
| SetForceQueryAction
| UpdateExploreChartStateAction
| SetCompatibilityAction
| HydrateExplore;
| HydrateExplore
| { type: typeof actions.RESET_SEMANTIC_SELECTIONS };
// Extended control state for dynamic form controls - uses Record for flexibility
// since control configs vary significantly across different control types
@@ -235,6 +236,31 @@ export default function exploreReducer(
action: ExploreAction,
): ExploreState {
const actionHandlers: ActionHandlers = {
[actions.RESET_SEMANTIC_SELECTIONS]() {
const version = state.datasource?.semantic_selection_version;
if (!version) return state;
// Build from an allowlist: an old title can exactly match a current ID.
const formData: QueryFormData = {
datasource: state.form_data.datasource,
viz_type: state.form_data.viz_type,
slice_id: state.form_data.slice_id,
semantic_selection_version: version,
};
const resetState: ExploreState = {
...state,
controls: {},
form_data: formData,
hiddenFormData: {},
controlsTransferred: [],
};
return {
...resetState,
controls: getControlsState(
resetState as Parameters<typeof getControlsState>[0],
formData,
) as ControlStateMapping,
};
},
[DYNAMIC_PLUGIN_CONTROLS_READY]() {
const typedAction = action as DynamicPluginControlsReadyAction;
return {
@@ -291,6 +317,7 @@ export default function exploreReducer(
prevDatasource.type !== newDatasource.type
) {
newFormData.datasource = newDatasource.uid;
delete newFormData.semantic_selection_version;
}
// reset control values for column/metric related controls
Object.entries(controls).forEach(([controlName, controlState]) => {

View File

@@ -45,6 +45,7 @@ test('maps the structure payload', async () => {
fetchMock.get('glob:*/api/v1/semantic_view/501/structure', {
result: {
name: 'orders',
semantic_selection_version: 'cube-member-id-v1',
dimensions: [{ name: 'Status', type: 'VARCHAR' }],
metrics: [{ name: 'revenue', definition: 'SUM(amount)' }],
},
@@ -53,6 +54,7 @@ test('maps the structure payload', async () => {
const structure = await fetchSemanticViewStructure(501);
expect(structure.name).toBe('orders');
expect(structure.semantic_selection_version).toBe('cube-member-id-v1');
expect(structure.dimensions).toEqual([{ name: 'Status', type: 'VARCHAR' }]);
expect(structure.metrics).toEqual([
{ name: 'revenue', definition: 'SUM(amount)' },
@@ -83,3 +85,14 @@ test('evicts the cache on failure so a later call refetches after recovery', asy
fetchMock.callHistory.calls('glob:*/api/v1/semantic_view/502/structure'),
).toHaveLength(2);
});
test.each([undefined, null])(
'does not invent a selection version for an unversioned structure (%s)',
async version => {
fetchMock.get('glob:*/api/v1/semantic_view/503/structure', {
result: { name: 'orders', semantic_selection_version: version },
});
const structure = await fetchSemanticViewStructure(503);
expect(structure.semantic_selection_version).toBeUndefined();
},
);

View File

@@ -77,6 +77,7 @@ export const mapSemanticTypeToGenericDataType = (
/** The slice of `GET /api/v1/semantic_view/<id>/structure` consumers rely on. */
export interface SemanticViewStructure {
name?: string;
semantic_selection_version?: string;
dimensions: { name: string; type: string }[];
metrics: { name: string; definition: string }[];
}
@@ -95,8 +96,18 @@ export const fetchSemanticViewStructure = async (
const endpoint = `/api/v1/semantic_view/${semanticViewId}/structure`;
try {
const response = await cachedSupersetGet({ endpoint });
const { name, dimensions = [], metrics = [] } = response.json?.result ?? {};
return { name, dimensions, metrics };
const {
name,
dimensions = [],
metrics = [],
semantic_selection_version,
} = response.json?.result ?? {};
return {
name,
dimensions,
metrics,
semantic_selection_version: semantic_selection_version ?? undefined,
};
} catch (error) {
// cacheWrapper caches the promise itself and never evicts on rejection, so
// a single failed response would poison this endpoint for the rest of the

View File

@@ -1183,6 +1183,12 @@ class ChartDataFilterSchema(Schema):
class ChartDataExtrasSchema(Schema):
semantic_selection_version: fields.String = fields.String(
allow_none=True,
metadata={
"description": "Identity encoding used for semantic member selections."
},
)
relative_start = fields.String(
metadata={
"description": "Start time for relative time deltas. "

View File

@@ -21,12 +21,13 @@ import inspect
import logging
from datetime import datetime
from pprint import pformat
from typing import Any, NamedTuple, TYPE_CHECKING
from typing import Any, cast, NamedTuple, TYPE_CHECKING
from flask import current_app
from flask_babel import gettext as _
from jinja2.exceptions import TemplateError
from pandas import DataFrame
from superset_core.semantic_layers.view import SemanticView as SemanticViewABC
from superset import feature_flag_manager
from superset.common.chart_data import ChartDataResultType
@@ -53,6 +54,7 @@ from superset.utils.json import json_int_dttm_ser
if TYPE_CHECKING:
from superset.connectors.sqla.models import BaseDatasource
from superset.semantic_layers.models import SemanticView
logger = logging.getLogger(__name__)
@@ -405,6 +407,39 @@ class QueryObject: # pylint: disable=too-many-instance-attributes
) -> QueryObjectValidationError | None:
"""Validate query object"""
try:
if self.datasource and self.datasource.type == "semantic_view":
implementation: SemanticViewABC = cast(
"SemanticView", self.datasource
).implementation
try:
implementation.validate_selection_version(
self.extras.get("semantic_selection_version")
)
except ValueError as ex:
if self.extras.get("semantic_selection_version") == (
"unverified-external-selections"
):
raise QueryObjectValidationError(
_(
"A dashboard filter or display control has "
"incompatible semantic selections. Dynamic group-by "
"is unsupported on versioned semantic views; remove "
"this chart from that control's scope. For other "
"filters or controls, reset and reselect fields or "
"saved values. If it targets another semantic view, "
"remove this chart from its scope."
)
) from ex
raise QueryObjectValidationError(
_(
"Saved semantic selections use an older identity format. "
"Reset and explicitly reselect the metrics and dimensions, "
"then save the chart. Display titles cannot be recovered "
"automatically. For API requests, select current member "
"IDs and supply semantic_selection_version from "
"datasource metadata or MCP list_metrics."
)
) from ex
self._validate_there_are_no_missing_series()
self._validate_no_have_duplicate_labels()
self._validate_time_offsets()

View File

@@ -336,6 +336,7 @@ def build_query_dict(
order: Sequence[OrderSpec] | None = None,
order_desc: bool = True,
rewrite_one_sided_time_range: bool = False,
semantic_selection_version: str | None = None,
) -> dict[str, Any]:
"""Assemble a QueryObject-shaped dict from a name-based request.
@@ -387,8 +388,13 @@ def build_query_dict(
query_dict["row_offset"] = offset
if time_column:
query_dict["granularity"] = time_column
extras: dict[str, str] = {}
if time_grain:
query_dict["extras"] = {"time_grain_sqla": time_grain}
extras["time_grain_sqla"] = time_grain
if semantic_selection_version is not None:
extras["semantic_selection_version"] = semantic_selection_version
if extras:
query_dict["extras"] = extras
if order:
# QueryObject.orderby is (name, ascending); the wire carries per-column
# descending flags, so invert each one rather than applying a single

View File

@@ -16,7 +16,7 @@
# under the License.
import hashlib
import logging
from typing import Any
from typing import Any, cast
from flask import current_app as app, make_response, request, Response
from flask_appbuilder.api import expose, protect, rison, safe
@@ -141,6 +141,12 @@ class DatasourceRestApi(BaseSupersetApi):
schema:
type: object
properties:
suggestions_status:
type: string
enum: [unavailable_versioned_view]
description: >-
Suggestions are disabled for versioned semantic views;
enter values manually.
result:
type: array
items:
@@ -177,15 +183,36 @@ class DatasourceRestApi(BaseSupersetApi):
except SupersetSecurityException as ex:
return self.response(403, message=ex.message)
row_limit = apply_max_row_limit(app.config["FILTER_SELECT_ROW_LIMIT"])
denormalize_column = not datasource.normalize_columns
return self._column_values_response(datasource, datasource_type, column_name)
def _column_values_response(
self, datasource: BaseDatasource, datasource_type: str, column_name: str
) -> FlaskResponse:
"""Return suggestions for an authorized datasource, gating before cache."""
# This route cannot prove the provenance of saved dimension names.
# Gate before cache access as well as provider execution.
if datasource_type == DatasourceType.SEMANTIC_VIEW.value:
from superset.semantic_layers.models import SemanticView
if (
cast(SemanticView, datasource).implementation.selection_identity_version
is not None
):
return self.response(
200,
result=[],
suggestions_status="unavailable_versioned_view",
)
row_limit: int = apply_max_row_limit(app.config["FILTER_SELECT_ROW_LIMIT"])
denormalize_column: bool = not datasource.normalize_columns
# Element-level operators (Contains any / Contains all) request the
# distinct array *elements* rather than distinct whole arrays.
array_elements = parse_boolean_string(request.args.get("array_elements"))
array_elements: bool = parse_boolean_string(request.args.get("array_elements"))
# Server-side search. Without it the client can only match against the
# bounded first page, so a value beyond ``FILTER_SELECT_ROW_LIMIT`` is
# unfindable on a high-cardinality column.
search = (request.args.get("q") or "").strip() or None
search: str | None = (request.args.get("q") or "").strip() or None
# Cache distinct column-value results so a dashboard with many filters
# backed by the same (often heavy) virtual dataset doesn't re-execute
@@ -208,8 +235,8 @@ class DatasourceRestApi(BaseSupersetApi):
# underlying SQL is edited.
# - ``uid`` / ``col`` / ``limit`` / ``denorm`` — basic query-shape
# isolation so different inputs never collide.
force = parse_boolean_string(request.args.get("force"))
cache_key = (
force: bool = parse_boolean_string(request.args.get("force"))
cache_key: str = (
"col_values:"
+ hashlib.sha256(
json.dumps(
@@ -228,6 +255,7 @@ class DatasourceRestApi(BaseSupersetApi):
).hexdigest()
)
cached: list[Any] | None
if (
not force
and (cached := cache_manager.data_cache.get(cache_key)) is not None
@@ -235,12 +263,12 @@ class DatasourceRestApi(BaseSupersetApi):
logger.debug(
"column-values cache HIT: uid=%s col=%s", datasource.uid, column_name
)
response = self.response(200, result=cached, limit=row_limit)
response: Response = self.response(200, result=cached, limit=row_limit)
response.headers["X-Cache-Status"] = "HIT"
return response
try:
payload = datasource.values_for_column(
payload: list[Any] = datasource.values_for_column(
column_name=column_name,
limit=row_limit,
denormalize_column=denormalize_column,
@@ -263,7 +291,10 @@ class DatasourceRestApi(BaseSupersetApi):
# Warn before caching very large payloads (high-cardinality columns)
# so operators can spot cache-memory pressure before Redis OOMs.
# Threshold is operator-tunable; defaults to 100k rows.
warn_threshold = app.config.get("FILTER_VALUES_CACHE_WARN_THRESHOLD", 100_000)
warn_threshold: int = app.config.get(
"FILTER_VALUES_CACHE_WARN_THRESHOLD", 100_000
)
payload_size: int
if (payload_size := len(payload)) > warn_threshold:
logger.warning(
"column-values payload exceeds cache-warn threshold: "
@@ -751,6 +782,7 @@ class DatasourceRestApi(BaseSupersetApi):
time_range=payload["time_range"],
time_grain=payload["time_grain"],
grain_column=grain_column,
semantic_selection_version=payload.get("semantic_selection_version"),
rewrite_one_sided_time_range=(
resolved.explorable.type == DatasourceType.SEMANTIC_VIEW.value
),

View File

@@ -170,6 +170,15 @@ class DatasourceQuerySchema(Schema):
QueryObject shape happens in ``superset.common.tabular_query``.
"""
semantic_selection_version: fields.String = fields.String(
allow_none=True,
load_default=None,
metadata={
"description": "Identity version from datasource metadata, after selecting "
"its current metric and dimension IDs. Never infer legacy IDs."
},
)
# Raw, not String: Metric/Column are `AdhocMetric | str` and
# `AdhocColumn | str`, so ad-hoc expressions are accepted for datasets
# exactly as ChartDataQueryObjectSchema accepts them. Semantic views

View File

@@ -345,6 +345,8 @@ To explore metrics across all data sources (built-in datasets + external semanti
}}) -> returns tabular results
- Use "dataset_id" when list_metrics returned source="builtin"
- Use "view_id" when list_metrics returned source="external"
- For external views, select current member IDs and pass semantic_selection_version
from list_metrics when non-null. Never infer a version or upgrade saved title keys.
To progressively refine a query (compatible dimensions/metrics):
- get_compatible_dimensions(request={{

View File

@@ -86,6 +86,7 @@ class MetricInfo(BaseModel):
dataset_name: str | None = None
view_id: int | None = None
view_name: str | None = None
semantic_selection_version: str | None = None
compatible_dimensions: list[DimensionInfo] = Field(default_factory=list)
@@ -202,6 +203,14 @@ class GetTableFilter(BaseModel):
class GetTableRequest(BaseModel):
"""Request schema for get_table."""
semantic_selection_version: str | None = Field(
default=None,
description=(
"Identity version returned by list_metrics for this view. Supply only "
"after explicitly selecting its current member IDs; never infer legacy IDs."
),
)
dataset_id: int | None = Field(
default=None,
description=(

View File

@@ -229,6 +229,9 @@ async def get_compatible_metrics(
source="external",
view_id=view.id,
view_name=view.name,
semantic_selection_version=(
view.implementation.selection_identity_version
),
)
for name in compatible_names
]

View File

@@ -31,6 +31,7 @@ from fastmcp import Context
from sqlalchemy.exc import SQLAlchemyError
from superset_core.mcp.decorators import tool, ToolAnnotations
from superset_core.semantic_layers.types import Dimension
from superset_core.semantic_layers.view import SemanticView as SemanticViewABC
from superset.commands.exceptions import CommandException
from superset.common.tabular_query import (
@@ -54,6 +55,7 @@ from superset.mcp_service.semantic_layer.schemas import (
from superset.mcp_service.utils.cache_utils import get_cache_status_from_result
from superset.mcp_service.utils.oauth2_utils import build_oauth2_redirect_message
from superset.mcp_service.utils.response_utils import format_data_columns
from superset.utils import json
if TYPE_CHECKING:
from superset.semantic_layers.models import SemanticView
@@ -186,17 +188,39 @@ def _resolve_external_view(
error_type="AccessDenied",
)
display_name = view.name
valid_columns = {c.column_name for c in view.columns}
valid_dttm_columns = {c.column_name for c in view.columns if c.is_dttm}
valid_metrics = {m.metric_name for m in view.metrics}
try:
implementation: SemanticViewABC = view.implementation
except json.JSONDecodeError:
return SemanticLayerError.create(
error="The semantic view configuration is invalid.",
error_type="ConfigurationError",
)
try:
implementation.validate_selection_version(request.semantic_selection_version)
except ValueError as ex:
return SemanticLayerError.create(
error=str(ex),
error_type="ValidationError",
)
return _resolve_external_view_metadata(request, view)
def _resolve_external_view_metadata(
request: GetTableRequest, view: "SemanticView"
) -> _ResolvedDatasource | SemanticLayerError:
"""Validate query fields on an accessible view with current selections."""
display_name: str = view.name
valid_columns: set[str] = {c.column_name for c in view.columns}
valid_dttm_columns: set[str] = {c.column_name for c in view.columns if c.is_dttm}
valid_metrics: set[str] = {m.metric_name for m in view.metrics}
warnings: list[str] = []
time_col = request.time_column
time_col: str | None = request.time_column
if time_col is None and request.time_range:
dttm_cols = [c for c in view.columns if c.is_dttm]
dttm_cols: list[str] = [c.column_name for c in view.columns if c.is_dttm]
if dttm_cols:
time_col = dttm_cols[0].column_name
time_col = dttm_cols[0]
else:
return SemanticLayerError.create(
error=(
@@ -309,6 +333,7 @@ def _build_query_dict(
dimensions=request.dimensions,
filters=[{"col": f.col, "op": f.op, "val": f.val} for f in request.filters],
time_range=request.time_range,
semantic_selection_version=request.semantic_selection_version,
limit=request.row_limit,
order=[(name, request.order_desc) for name in request.order_by],
order_desc=request.order_desc,
@@ -569,7 +594,10 @@ async def get_table(
Workflow:
1. list_metrics -> discover metrics
2. get_compatible_dimensions -> discover dimensions for the chosen metrics
3. get_table -> query with chosen metrics and dimensions
3. Explicitly select current member IDs from those discovery responses.
4. get_table -> query with those IDs and, for a versioned external view,
its semantic_selection_version from list_metrics. Never upgrade old
saved title keys automatically, even if a title looks like an ID.
Example (built-in):
```json
@@ -582,12 +610,13 @@ async def get_table(
}
```
Example (external):
Example (versioned external view; use the discovered version and IDs):
```json
{
"view_id": 5,
"metrics": ["bookings"],
"dimensions": ["listing__country_name"],
"metrics": ["Orders.bookings"],
"dimensions": ["Orders.country"],
"semantic_selection_version": "cube-member-id-v1",
"row_limit": 100
}
```

View File

@@ -218,6 +218,9 @@ async def _collect_external_metrics(
source="external",
view_id=view.id,
view_name=view.name,
semantic_selection_version=(
view.implementation.selection_identity_version
),
compatible_dimensions=compat_dims,
)
)

View File

@@ -289,6 +289,9 @@ class SemanticViewRestApi(BaseSupersetModelRestApi):
200,
result={
"name": view.name,
"semantic_selection_version": (
view.implementation.selection_identity_version
),
"description": view.description,
"cache_timeout": view.cache_timeout,
"dimensions": dimensions,

View File

@@ -427,6 +427,9 @@ def map_query_object(query_object: ValidatedQueryObject) -> list[SemanticQuery]:
limit=limit,
offset=offset,
group_limit=group_limit,
selection_identity_version=query_object.extras.get(
"semantic_selection_version"
),
)
)
@@ -1106,6 +1109,9 @@ def validate_query_object(
query_object = cast(ValidatedQueryObject, query_object)
query_object.datasource.implementation.validate_selection_version(
query_object.extras.get("semantic_selection_version")
)
_validate_metrics(query_object)
_validate_dimensions(query_object)
_validate_filters(query_object)

View File

@@ -604,6 +604,9 @@ class SemanticView(AuditMixinNullable, Model):
"semantic_view_features": sorted(
_feature_value(feature) for feature in self.implementation.features
),
"semantic_selection_version": (
self.implementation.selection_identity_version
),
"name": self.name,
"columns": [
{

View File

@@ -310,6 +310,8 @@ class ExplorableData(TypedDict, total=False):
normalize_columns: Whether to normalize column names
"""
semantic_selection_version: str | None
# Core fields from BaseDatasource.data
id: int | str # String for UUID-based explorables like SemanticView
uid: str

View File

@@ -899,6 +899,14 @@ msgstr ""
msgid "A comma-separated list of schemas that files are allowed to upload to."
msgstr ""
msgid ""
"A dashboard filter or display control has incompatible semantic "
"selections. Dynamic group-by is unsupported on versioned semantic views; "
"remove this chart from that control's scope. For other filters or "
"controls, reset and reselect fields or saved values. If it targets "
"another semantic view, remove this chart from its scope."
msgstr ""
msgid "A database port is required when connecting via SSH Tunnel."
msgstr ""
@@ -3227,6 +3235,12 @@ msgstr ""
msgid "Choose columns to read"
msgstr ""
msgid "Choose current semantic fields"
msgstr ""
msgid "Choose current semantic filter fields"
msgstr ""
msgid "Choose from existing dashboard filters and select a value to refine your report results."
msgstr ""
@@ -5962,6 +5976,9 @@ msgstr ""
msgid "Enter theme name"
msgstr ""
msgid "Enter values"
msgstr ""
msgid "Enter your current password"
msgstr ""
@@ -10838,6 +10855,9 @@ msgstr ""
msgid "Resample rule would project %(rows)s rows, exceeding the limit of %(max)s rows"
msgstr ""
msgid "Reselect saved filter values"
msgstr ""
msgid "Reset"
msgstr ""
@@ -10847,6 +10867,9 @@ msgstr ""
msgid "Reset all folders to default"
msgstr ""
msgid "Reset and reselect values"
msgstr ""
msgid "Reset columns"
msgstr ""
@@ -11360,6 +11383,14 @@ msgstr ""
msgid "Saved query parameters are invalid."
msgstr ""
msgid ""
"Saved semantic selections use an older identity format. Reset and "
"explicitly reselect the metrics and dimensions, then save the chart. "
"Display titles cannot be recovered automatically. For API requests, "
"select current member IDs and supply semantic_selection_version from "
"datasource metadata or MCP list_metrics."
msgstr ""
msgid "Saving..."
msgstr ""
@@ -12752,6 +12783,21 @@ msgstr ""
msgid "Start date included in time range"
msgstr ""
msgid "Start field selection"
msgstr ""
msgid ""
"Start field selection using current member IDs. This clears any existing "
"field selections, pre-filters, sorting, defaults and dependencies. Saved "
"display titles cannot be recovered automatically."
msgstr ""
msgid ""
"Start field selection using current member IDs. This clears any existing "
"query and formatting settings. Saved display titles cannot be safely "
"mapped to member IDs. The saved chart is unchanged until you save."
msgstr ""
msgid "Start y-axis at 0"
msgstr ""
@@ -12914,6 +12960,12 @@ msgstr ""
msgid "Suffix to apply after the percentage display"
msgstr ""
msgid "Suggestions are unavailable for this view. Enter a value manually."
msgstr ""
msgid "Suggestions are unavailable. Enter values manually."
msgstr ""
msgid "Suggestions could not be loaded. You can still type a value."
msgstr ""
@@ -14541,6 +14593,11 @@ msgstr ""
msgid "This password is too common; please choose a less guessable one."
msgstr ""
msgid ""
"This saved filter state predates the member-ID format. Reset it and "
"explicitly choose its values again."
msgstr ""
msgid ""
"This section allows you to configure how to use the slice\n"
" to generate annotations."
@@ -15596,6 +15653,11 @@ msgstr ""
msgid "Values are dependent on other filters"
msgstr ""
msgid ""
"Values cannot be refreshed in All mode for this semantic view. Saved "
"values are preserved. Switch to Members to enter values manually."
msgstr ""
msgid "Values dependent on"
msgstr ""

View File

@@ -87,7 +87,9 @@ def _make_dataset(dataset_id: int = 42) -> MagicMock:
def _make_view(view_id: int = 5) -> MagicMock:
view: MagicMock = MagicMock()
view: MagicMock = MagicMock(
implementation=MagicMock(selection_identity_version=None)
)
view.id = view_id
view.name = f"view_{view_id}"
view.raise_for_access = MagicMock(return_value=None)
@@ -177,9 +179,14 @@ async def test_get_compatible_metrics_builtin_unknown_selection(
@pytest.mark.asyncio
async def test_get_compatible_metrics_external_happy_path(mcp_server: FastMCP) -> None:
@pytest.mark.parametrize("version", [None, "cube-member-id-v1"])
async def test_get_compatible_metrics_external_happy_path(
mcp_server: FastMCP,
version: str | None,
) -> None:
"""External views delegate to view.get_compatible_metrics()."""
mock_view = _make_view(5)
mock_view: MagicMock = _make_view(5)
mock_view.implementation.selection_identity_version = version
with patch(
"superset.daos.semantic_layer.SemanticViewDAO.find_by_id",
@@ -195,6 +202,7 @@ async def test_get_compatible_metrics_external_happy_path(mcp_server: FastMCP) -
assert data["success"] is True
assert data["source"] == "external"
assert [m["name"] for m in data["compatible_metrics"]] == ["bookings"]
assert data["compatible_metrics"][0]["semantic_selection_version"] == version
mock_view.get_compatible_metrics.assert_called_once_with([], ["country_name"])

View File

@@ -1503,3 +1503,42 @@ def test_time_range_uses_the_selected_grain_axis(temporal_view: MagicMock) -> No
assert query["filters"] == [
{"col": "signup_date", "op": "TEMPORAL_RANGE", "val": request.time_range}
]
@pytest.mark.parametrize("version", [None, "stale-version"])
def test_external_view_selection_version_is_validation_error(
temporal_view: MagicMock,
version: str | None,
) -> None:
temporal_view.implementation.validate_selection_version.side_effect = ValueError(
"Please explicitly reselect current member IDs"
)
result: object = get_table_module._resolve_external_view(
GetTableRequest(
view_id=5, metrics=["bookings"], semantic_selection_version=version
)
)
assert isinstance(result, SemanticLayerError)
assert result.error_type == "ValidationError"
assert "explicitly reselect" in result.error
temporal_view.implementation.validate_selection_version.assert_called_once_with(
version
)
def test_external_view_malformed_configuration_is_sanitized(
temporal_view: MagicMock,
) -> None:
from unittest.mock import PropertyMock
from superset.utils import json
type(temporal_view).implementation = PropertyMock(
side_effect=json.JSONDecodeError("PRIVATE_CONFIG", "{", 1)
)
result: object = get_table_module._resolve_external_view(
GetTableRequest(view_id=5, metrics=["bookings"])
)
assert isinstance(result, SemanticLayerError)
assert result.error_type == "ConfigurationError"
assert result.error == "The semantic view configuration is invalid."

View File

@@ -106,7 +106,9 @@ def _make_dataset(dataset_id: int = 1) -> MagicMock:
def _make_view(view_id: int = 5) -> MagicMock:
view = MagicMock()
view: MagicMock = MagicMock(
implementation=MagicMock(selection_identity_version=None)
)
view.id = view_id
view.name = f"view_{view_id}"
view.raise_for_access = MagicMock(return_value=None)
@@ -381,7 +383,10 @@ async def test_list_metrics_external_includes_verbose_name(
mcp_server: FastMCP,
) -> None:
"""External metrics include verbose_name, matching the builtin path."""
mock_view = _make_view(5)
mock_view: MagicMock = _make_view(5)
mock_view.implementation.configure_mock(
selection_identity_version="cube-member-id-v1"
)
mock_view.metrics[0].verbose_name = "Bookings Count"
with patch.object(list_metrics_module, "SemanticViewDAO") as mock_view_dao:
@@ -396,6 +401,7 @@ async def test_list_metrics_external_includes_verbose_name(
metrics = {m["name"]: m for m in data["metrics"]}
assert metrics["bookings"]["verbose_name"] == "Bookings Count"
assert metrics["bookings"]["semantic_selection_version"] == "cube-member-id-v1"
@pytest.mark.asyncio

View File

@@ -92,7 +92,9 @@ TOOL_BUDGETS = {
"get_role_info": 900,
"get_saved_query_info": 1_200,
"get_schema": 1_100,
"get_table": 3_900,
# Includes semantic_selection_version and its explicit-reselection guidance:
# 3,957 bytes, rounded up plus the standard 100-byte headroom.
"get_table": 4_100,
"get_tag_info": 1_000,
"get_task_info": 1_100,
"get_theme_info": 1_000,

View File

@@ -2568,7 +2568,9 @@ def test_get_semantic_view_structure(
mock_metric.definition = "SIMPLE"
mock_metric.description = "Order count"
mock_view = MagicMock()
mock_view: MagicMock = MagicMock(
implementation=MagicMock(selection_identity_version="cube-member-id-v1")
)
mock_view.name = "orders"
mock_view.description = "All orders"
mock_view.cache_timeout = 600
@@ -2585,6 +2587,7 @@ def test_get_semantic_view_structure(
assert response.status_code == 200
result = response.json["result"]
assert result["name"] == "orders"
assert result["semantic_selection_version"] == "cube-member-id-v1"
# The edit modal has no detail route to hydrate from, so these must ride
# along with the structure or a saved description cannot be read back.
assert result["description"] == "All orders"
@@ -2670,7 +2673,9 @@ def test_get_semantic_view_structure_no_grain(
mock_dim.description = None
mock_dim.grain = None
mock_view = MagicMock()
mock_view: MagicMock = MagicMock(
implementation=MagicMock(selection_identity_version=None)
)
mock_view.name = "customers"
mock_view.description = None
mock_view.cache_timeout = None
@@ -2688,6 +2693,7 @@ def test_get_semantic_view_structure_no_grain(
result = response.json["result"]
assert result["dimensions"][0]["grain"] is None
assert result["metrics"] == []
assert result["semantic_selection_version"] is None
# An unset description must come back as null, not be omitted: the modal
# distinguishes "cleared" from "absent" when hydrating.
assert result["description"] is None

View File

@@ -93,6 +93,11 @@ class MockSemanticView:
self._metrics = metrics
self.features = features
selection_identity_version: str | None = None
def validate_selection_version(self, version: object) -> None:
return None
def uid(self) -> str:
return "mock_semantic_view"

View File

@@ -611,6 +611,7 @@ def test_semantic_view_data(
"""Test SemanticView data property."""
from superset.semantic_layers.models import SemanticLayer
mock_implementation.configure_mock(selection_identity_version="cube-member-id-v1")
layer = SemanticLayer()
layer.name = "My Semantic Layer"
layer.uuid = uuid.UUID("87654321-4321-8765-4321-876543218765")
@@ -636,6 +637,7 @@ def test_semantic_view_data(
assert data["id"] == 1
assert data["uid"] == "semantic_view_uid_123"
assert data["type"] == "semantic_view"
assert data["semantic_selection_version"] == "cube-member-id-v1"
assert data["name"] == "Orders View"
assert data["description"] == "View of order data"
assert data["cache_timeout"] == 3600

View File

@@ -0,0 +1,293 @@
# 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.
from __future__ import annotations
from typing import Any
from unittest.mock import MagicMock, patch, PropertyMock
import pyarrow as pa
import pytest
from superset_core.semantic_layers.types import (
Dimension,
Filter,
Metric,
SemanticQuery,
SemanticResult,
)
from superset_core.semantic_layers.view import SemanticView, SemanticViewFeature
from superset.charts.schemas import ChartDataExtrasSchema
from superset.common.query_context_processor import QueryContextProcessor
from superset.common.query_object import QueryObject
from superset.exceptions import QueryObjectValidationError
from superset.semantic_layers.mapper import map_query_object, validate_query_object
from superset.utils import json
class IdentityView(SemanticView):
name: str = "Orders"
features: frozenset[SemanticViewFeature] = frozenset()
selection_identity_version: str | None = "cube-member-id-v1"
def __init__(self) -> None:
self.metrics: set[Metric] = {
Metric(
id="Orders.b",
name="Orders.b",
type=pa.float64(),
definition="Orders.b",
verbose_name="Total",
)
}
self.dimensions: set[Dimension] = set()
def get_compatible_metrics(
self, selected_metrics: set[Metric], selected_dimensions: set[Dimension]
) -> set[Metric]:
return self.metrics
def get_compatible_dimensions(
self, selected_metrics: set[Metric], selected_dimensions: set[Dimension]
) -> set[Dimension]:
return self.dimensions
def uid(self) -> str:
return "identity-test"
def get_metrics(self) -> set[Metric]:
return self.metrics
def get_dimensions(self) -> set[Dimension]:
return self.dimensions
def get_values(
self, dimension: Dimension, filters: set[Filter] | None = None
) -> SemanticResult:
raise AssertionError("legacy request must not execute")
def get_table(self, query: SemanticQuery) -> SemanticResult:
raise AssertionError("legacy request must not execute")
def get_row_count(self, query: SemanticQuery) -> SemanticResult:
raise AssertionError("legacy request must not execute")
@pytest.mark.parametrize("version", [None, "old", True, 1])
def test_legacy_id_looking_title_rejected_before_cache_and_mapper(
version: object,
) -> None:
datasource: MagicMock = MagicMock(
type="semantic_view", implementation=IdentityView()
)
query: QueryObject = QueryObject(
datasource=datasource,
metrics=["Orders.b"],
extras={"semantic_selection_version": version},
)
processor: QueryContextProcessor = QueryContextProcessor(MagicMock())
cache: MagicMock
with patch(
"superset.common.query_context_processor.QueryCacheManager.get"
) as cache:
with pytest.raises(QueryObjectValidationError, match="explicitly reselect"):
processor.get_df_payload_result(query, force_cached=True)
cache.assert_not_called()
with pytest.raises(ValueError, match="explicitly reselect"):
validate_query_object(query)
def test_current_version_reaches_semantic_query_and_other_providers_opt_out() -> None:
implementation: IdentityView = IdentityView()
datasource: MagicMock = MagicMock(
type="semantic_view", implementation=implementation, fetch_values_predicate=None
)
query: QueryObject = QueryObject(
datasource=datasource,
metrics=["Orders.b"],
extras={"semantic_selection_version": "cube-member-id-v1"},
series_limit=0,
)
assert query.validate() is None
assert validate_query_object(query)
mapped: list[SemanticQuery] = map_query_object(query)
assert mapped[0].metrics[0].id == "Orders.b"
assert mapped[0].selection_identity_version == "cube-member-id-v1"
implementation.selection_identity_version = None
query.extras = {}
assert query.validate() is None
assert validate_query_object(query)
def test_query_extras_schema_preserves_saved_version_without_upgrading_legacy() -> None:
"""Keep the explicit marker across JSON and schema deserialization."""
schema: ChartDataExtrasSchema = ChartDataExtrasSchema()
saved: str = json.dumps({"semantic_selection_version": "cube-member-id-v1"})
assert schema.load(json.loads(saved))["semantic_selection_version"] == (
"cube-member-id-v1"
)
assert "semantic_selection_version" not in schema.load({})
def test_incompatible_overlay_reports_filter_scope_remediation() -> None:
"""A chart reset alone cannot repair an incompatible dashboard overlay."""
query: QueryObject = QueryObject(
datasource=MagicMock(type="semantic_view", implementation=IdentityView()),
metrics=["Orders.b"],
extras={"semantic_selection_version": "unverified-external-selections"},
)
with pytest.raises(
QueryObjectValidationError,
match="Dynamic group-by is unsupported.*remove this chart from its scope",
):
query.validate()
@pytest.mark.parametrize("entrypoint", ["rest", "mcp"])
@pytest.mark.parametrize("version", [None, "cube-member-id-v1"])
def test_name_based_entrypoints_preserve_explicit_identity_version(
entrypoint: str, version: str | None
) -> None:
"""Exercise each wire schema and builder through the real validation/mapper."""
from superset.datasource.api import DatasourceRestApi
from superset.datasource.schemas import DatasourceQuerySchema
from superset.mcp_service.semantic_layer.schemas import GetTableRequest
from superset.mcp_service.semantic_layer.tool.get_table import _build_query_dict
datasource: MagicMock = MagicMock(
type="semantic_view", implementation=IdentityView(), fetch_values_predicate=None
)
wire: dict[str, Any] = {
"metrics": ["Orders.b"],
"semantic_selection_version": version,
}
query_dict: dict[str, Any]
if entrypoint == "rest":
payload: dict[str, Any] = DatasourceQuerySchema().load(wire)
resolved: MagicMock = MagicMock(explorable=datasource, time_column=None)
query_dict = DatasourceRestApi._build_query_dict(resolved, payload, None)
else:
query_dict = _build_query_dict(GetTableRequest(view_id=7, **wire), None)
query: QueryObject = QueryObject(
datasource=datasource, series_limit=0, **query_dict
)
if version is None:
assert "semantic_selection_version" not in query_dict.get("extras", {})
processor: QueryContextProcessor = QueryContextProcessor(MagicMock())
cache: MagicMock
with patch(
"superset.common.query_context_processor.QueryCacheManager.get"
) as cache:
with pytest.raises(QueryObjectValidationError, match="explicitly reselect"):
processor.get_df_payload_result(query, force_cached=True)
cache.assert_not_called()
else:
assert query.validate() is None
assert validate_query_object(query)
mapped: list[SemanticQuery] = map_query_object(query)
assert mapped[0].selection_identity_version == version
assert mapped[0].metrics[0].id == "Orders.b"
def test_name_based_version_coexists_with_time_grain_without_auto_upgrade() -> None:
from superset.common.tabular_query import build_query_dict
query: dict[str, Any] = build_query_dict(
metrics=["Orders.b"],
time_grain="P1D",
semantic_selection_version="cube-member-id-v1",
)
assert query["extras"] == {
"time_grain_sqla": "P1D",
"semantic_selection_version": "cube-member-id-v1",
}
assert build_query_dict(metrics=["Orders.b"], time_grain="P1D")["extras"] == {
"time_grain_sqla": "P1D"
}
def test_malformed_configuration_is_not_a_selection_version_error() -> None:
datasource: MagicMock = MagicMock(type="semantic_view")
type(datasource).implementation = PropertyMock(
side_effect=json.JSONDecodeError("private configuration", "{", 1)
)
query: QueryObject = QueryObject(
datasource=datasource,
metrics=["Orders.b"],
extras={"semantic_selection_version": "cube-member-id-v1"},
)
with pytest.raises(json.JSONDecodeError):
query.validate()
@pytest.mark.parametrize(
"datasource_type,version",
[
("semantic_view", "cube-member-id-v1"),
("semantic_view", None),
("table", None),
],
)
def test_column_suggestions_version_gate_precedes_cache(
datasource_type: str,
version: str | None,
) -> None:
from inspect import unwrap
from types import MethodType
from flask import Flask
from superset.datasource.api import DatasourceRestApi
app: Flask = Flask(__name__)
app.config["FILTER_SELECT_ROW_LIMIT"] = 100
app.config["SQL_MAX_ROW"] = 1000
datasource: MagicMock = MagicMock(
type=datasource_type, uid="1", normalize_columns=False, changed_on="fixed"
)
datasource.implementation.selection_identity_version = version
api: MagicMock = MagicMock()
api._column_values_response = MethodType(
DatasourceRestApi._column_values_response, api
)
cache: MagicMock
with (
app.test_request_context(),
patch(
"superset.datasource.api.DatasourceDAO.get_datasource",
return_value=datasource,
),
patch("superset.datasource.api.cache_manager") as cache,
patch(
"superset.datasource.api.security_manager.get_rls_cache_key",
return_value=[],
),
):
cache.data_cache.get.return_value = ["cached"]
unwrap(DatasourceRestApi.get_column_values)(api, datasource_type, 1, "Orders.b")
datasource.raise_for_access.assert_called_once()
if version:
cache.data_cache.get.assert_not_called()
datasource.values_for_column.assert_not_called()
api.response.assert_called_once_with(
200,
result=[],
suggestions_status="unavailable_versioned_view",
)
else:
cache.data_cache.get.assert_called_once()
assert api.response.call_args.kwargs["result"] == ["cached"]

View File

@@ -39,7 +39,7 @@ from superset.semantic_layers.models import SemanticView
@pytest.fixture
def semantic_view_datasource(mocker: MockerFixture) -> SemanticView:
implementation = MagicMock()
implementation: MagicMock = MagicMock(selection_identity_version=None)
implementation.uid.return_value = "semantic_view_uid_123"
implementation.get_dimensions.return_value = [
Dimension(id="orders.category", name="category", type=pa.utf8()),
@@ -125,3 +125,22 @@ def test_semantic_view_values_endpoint_unknown_column_is_a_400(
assert response.status_code == 400
assert "no_such_column" in response.json["message"]
def test_versioned_suggestions_are_unavailable_before_cache(
client: Any,
full_api_access: None,
semantic_view_datasource: SemanticView,
mocker: MockerFixture,
) -> None:
implementation: MagicMock = cast(MagicMock, semantic_view_datasource.implementation)
implementation.selection_identity_version = "cube-member-id-v1"
cache: MagicMock = mocker.patch("superset.datasource.api.cache_manager").data_cache
response: Any = _get(client, "category/values/")
assert response.status_code == 200
assert response.json == {
"result": [],
"suggestions_status": "unavailable_versioned_view",
}
cache.get.assert_not_called()
implementation.get_values.assert_not_called()