fix(semantic-layer): require explicit member identity reselection (#44370)
This commit is contained in:
48
UPDATING.md
48
UPDATING.md
@@ -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
|
||||
|
||||
62
docs/docs/using-superset/semantic-member-selections.mdx
Normal file
62
docs/docs/using-superset/semantic-member-selections.mdx
Normal 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.
|
||||
18
docs/static/resources/openapi.json
vendored
18
docs/static/resources/openapi.json
vendored
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -45,6 +45,13 @@ export const datasourceAndVizType: ControlPanelSectionConfig = {
|
||||
['datasource'],
|
||||
['viz_type'],
|
||||
[
|
||||
{
|
||||
name: 'semantic_selection_version',
|
||||
config: {
|
||||
type: 'HiddenControl',
|
||||
hidden: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'slice_id',
|
||||
config: {
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -46,3 +46,5 @@ export { default as makeApi } from './api/v1/makeApi';
|
||||
|
||||
// API Callers
|
||||
export { ApiLegacy, ApiV1 };
|
||||
|
||||
export { getSemanticSelectionSources } from './semanticSelection';
|
||||
|
||||
@@ -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 }] : [];
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
/**
|
||||
|
||||
@@ -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');
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
)}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -27,6 +27,13 @@ export const datasourceAndVizType: ControlPanelSectionConfig = {
|
||||
['datasource'],
|
||||
['viz_type'],
|
||||
[
|
||||
{
|
||||
name: 'semantic_selection_version',
|
||||
config: {
|
||||
type: 'HiddenControl',
|
||||
hidden: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'slice_id',
|
||||
config: {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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]) => {
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. "
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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=(
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
|
||||
@@ -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"])
|
||||
|
||||
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
293
tests/unit_tests/semantic_layers/selection_identity_test.py
Normal file
293
tests/unit_tests/semantic_layers/selection_identity_test.py
Normal 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"]
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user