This is an automated email from the ASF dual-hosted git repository. betodealmeida pushed a commit to branch semantic-view-dashboard-filters in repository https://gitbox.apache.org/repos/asf/superset.git
commit 6a82eaded7dc366a6efaffb53eae93e1cec98194 Author: Beto Dealmeida <[email protected]> AuthorDate: Wed May 27 14:15:03 2026 -0400 feat(semantic layers): dashboard filters --- .../src/superset_core/semantic_layers/types.py | 2 +- superset-frontend/package-lock.json | 2 +- .../superset-ui-core/src/query/types/Dashboard.ts | 1 + .../FilterBar/FilterControls/FilterValue.tsx | 8 +- .../FiltersConfigForm/ColumnSelect.tsx | 90 +++++++--- .../FiltersConfigForm/DatasetSelect.test.tsx | 45 +++++ .../FiltersConfigForm/DatasetSelect.tsx | 99 ++++++++++- .../FiltersConfigForm/FiltersConfigForm.tsx | 192 +++++++++++++++------ .../FiltersConfigForm/getControlItemsMap.tsx | 3 + .../FiltersConfigForm/utils.test.ts | 74 ++++++++ .../FiltersConfigModal/FiltersConfigForm/utils.ts | 90 ++++++++++ .../transformers/customizationTransformer.ts | 4 + .../transformers/filterTransformer.ts | 4 + .../nativeFilters/FiltersConfigModal/types.ts | 2 + .../nativeFilters/FiltersConfigModal/utils.ts | 3 + .../dashboard/components/nativeFilters/utils.ts | 5 +- .../src/dashboard/reducers/dashboardInfo.test.ts | 24 ++- .../src/dashboard/reducers/dashboardInfo.ts | 1 + .../features/datasets/DatasetSelectLabel/index.tsx | 1 + superset/semantic_layers/api.py | 9 +- superset/semantic_layers/mapper.py | 6 +- tests/unit_tests/semantic_layers/mapper_test.py | 27 ++- 22 files changed, 598 insertions(+), 94 deletions(-) diff --git a/superset-core/src/superset_core/semantic_layers/types.py b/superset-core/src/superset_core/semantic_layers/types.py index 3bfa9e8c315..c26667b8bb2 100644 --- a/superset-core/src/superset_core/semantic_layers/types.py +++ b/superset-core/src/superset_core/semantic_layers/types.py @@ -158,7 +158,7 @@ class Filter: type: PredicateType column: Dimension | Metric | None operator: Operator - value: FilterValues | frozenset[FilterValues] + value: FilterValues | tuple[FilterValues, ...] | frozenset[FilterValues] class OrderDirection(enum.Enum): diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index 0902547f989..25736c00216 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -50085,7 +50085,7 @@ "@math.gl/web-mercator": "^4.1.0", "mapbox-gl": "^3.24.0", "maplibre-gl": "^5.24.0", - "react-map-gl": "^8.1.1", + "react-map-gl": "^8.1.0", "supercluster": "^8.0.1" }, "peerDependencies": { diff --git a/superset-frontend/packages/superset-ui-core/src/query/types/Dashboard.ts b/superset-frontend/packages/superset-ui-core/src/query/types/Dashboard.ts index 3fdf674f4f5..3ad01c1b701 100644 --- a/superset-frontend/packages/superset-ui-core/src/query/types/Dashboard.ts +++ b/superset-frontend/packages/superset-ui-core/src/query/types/Dashboard.ts @@ -38,6 +38,7 @@ export interface NativeFilterScope { export interface NativeFilterTarget { datasetId: number; column: NativeFilterColumn; + datasourceType?: string; // maybe someday support this? // show values from these columns in the filter options selector diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterValue.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterValue.tsx index c23f2578718..8757f5c7e1b 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterValue.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterValue.tsx @@ -177,8 +177,13 @@ const FilterValue: FC<FilterValueProps> = ({ const [target] = targets || []; const { datasetId, + datasourceType, column = {}, - }: Partial<{ datasetId: number; column: { name?: string } }> = target || {}; + }: Partial<{ + datasetId: number; + datasourceType: string; + column: { name?: string }; + }> = target || {}; const groupby = column?.name; const hasDataSource = !!datasetId; const [isLoading, setIsLoading] = useState<boolean>(hasDataSource); @@ -212,6 +217,7 @@ const FilterValue: FC<FilterValueProps> = ({ const newFormData = getFormData({ ...filter, datasetId, + datasourceType, dependencies, groupby, adhoc_filters: adhocFilters, diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/ColumnSelect.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/ColumnSelect.tsx index 2e8f43bb21f..f956e164b76 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/ColumnSelect.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/ColumnSelect.tsx @@ -19,9 +19,11 @@ import { useCallback, useState, useMemo, useEffect } from 'react'; import rison from 'rison'; import { t } from '@apache-superset/core/translation'; +import { GenericDataType } from '@apache-superset/core/common'; import { Column, ensureIsArray, + JsonResponse, useChangeEffect, getClientErrorObject, } from '@superset-ui/core'; @@ -29,6 +31,7 @@ import { type FormInstance, Select } from '@superset-ui/core/components'; import { useToasts } from 'src/components/MessageToasts/withToasts'; import { cachedSupersetGet } from 'src/utils/cachedSupersetGet'; import { NativeFiltersForm, NativeFiltersFormItem } from '../types'; +import { mapSemanticTypeToGenericDataType } from './utils'; interface ColumnSelectProps { allowClear?: boolean; @@ -37,6 +40,7 @@ interface ColumnSelectProps { formField?: keyof NativeFiltersFormItem; filterId: string; datasetId?: number; + datasourceType?: string; value?: string | string[]; onChange?: (value: string) => void; mode?: 'multiple'; @@ -51,6 +55,7 @@ export function ColumnSelect({ formField = 'column', filterId, datasetId, + datasourceType, value, onChange, mode, @@ -86,25 +91,68 @@ export function ColumnSelect({ } }, [currentColumn, currentFilterType, resetColumnField]); - useChangeEffect(datasetId, previous => { + // Use a compound key so the effect re-fires when either the dataset ID or + // the datasource type changes. Datasets and semantic views have independent + // ID sequences, so switching between them with the same numeric ID must still + // trigger a column re-fetch. + const datasourceKey = `${datasetId}__${datasourceType || 'table'}`; + useChangeEffect(datasourceKey, previous => { if (previous != null) { setColumns([]); resetColumnField(); } if (datasetId != null) { setLoading(true); - cachedSupersetGet({ - endpoint: `/api/v1/dataset/${datasetId}?q=${rison.encode({ - columns: [ - 'columns.column_name', - 'columns.is_dttm', - 'columns.type_generic', - 'columns.filterable', - ], - })}`, - }) - .then( - ({ json: { result } }) => { + const handleError = async ( + badResponse: Parameters<typeof getClientErrorObject>[0], + ) => { + const { error, message } = await getClientErrorObject(badResponse); + let errorText = message || error || t('An error has occurred'); + if (message === 'Forbidden') { + errorText = t('You do not have permission to edit this dashboard'); + } + addDangerToast(errorText); + }; + + if (datasourceType === 'semantic_view') { + cachedSupersetGet({ + endpoint: `/api/v1/semantic_view/${datasetId}/structure`, + }) + .then((response: JsonResponse) => { + const { dimensions = [] } = response.json?.result ?? {}; + const cols: Column[] = dimensions.map( + (dim: { name: string; type: string }) => { + const mappedType = mapSemanticTypeToGenericDataType(dim.type); + return { + column_name: dim.name, + is_dttm: mappedType === GenericDataType.Temporal, + type_generic: mappedType, + filterable: true, + }; + }, + ); + const lookupValue = Array.isArray(value) ? value : [value]; + const valueExists = cols.some((column: Column) => + lookupValue?.includes(column.column_name), + ); + if (!valueExists) { + resetColumnField(); + } + setColumns(cols); + }, handleError) + .finally(() => setLoading(false)); + } else { + cachedSupersetGet({ + endpoint: `/api/v1/dataset/${datasetId}?q=${rison.encode({ + columns: [ + 'columns.column_name', + 'columns.is_dttm', + 'columns.type_generic', + 'columns.filterable', + ], + })}`, + }) + .then(({ json: { result } }) => { const lookupValue = Array.isArray(value) ? value : [value]; const valueExists = result.columns.some((column: Column) => lookupValue?.includes(column.column_name), @@ -113,19 +161,9 @@ export function ColumnSelect({ resetColumnField(); } setColumns(result.columns); - }, - async badResponse => { - const { error, message } = await getClientErrorObject(badResponse); - let errorText = message || error || t('An error has occurred'); - if (message === 'Forbidden') { - errorText = t( - 'You do not have permission to edit this dashboard', - ); - } - addDangerToast(errorText); - }, - ) - .finally(() => setLoading(false)); + }, handleError) + .finally(() => setLoading(false)); + } } }); diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/DatasetSelect.test.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/DatasetSelect.test.tsx index fb8632b09d5..be579c9848b 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/DatasetSelect.test.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/DatasetSelect.test.tsx @@ -265,3 +265,48 @@ test('returns total count from API when data is filtered', async () => { expect(result.data).toHaveLength(2); expect(result.data.find(item => item.value === 2)).toBeUndefined(); }); + +test('does not exclude semantic views that share dataset IDs', async () => { + supersetGetCache.clear(); + fetchMock.clearHistory().removeRoutes(); + + const originalFeatureFlags = window.featureFlags; + window.featureFlags = { + ...originalFeatureFlags, + SEMANTIC_LAYERS: true, + }; + + try { + fetchMock.get('glob:*/api/v1/datasource/*', { + result: [ + { + id: 7, + table_name: 'orders_dataset', + kind: 'physical', + database: { database_name: 'examples' }, + schema: 'public', + }, + { + id: 7, + table_name: 'orders_semantic_view', + kind: 'semantic_view', + database: { database_name: 'semantic_layer' }, + schema: null, + }, + ], + count: 2, + }); + + const result = await loadDatasetOptions('', 0, 100, [7]); + + expect(result.totalCount).toBe(2); + expect(result.data).toHaveLength(1); + expect(result.data[0]).toMatchObject({ + value: 'sv:7', + kind: 'semantic_view', + table_name: 'orders_semantic_view', + }); + } finally { + window.featureFlags = originalFeatureFlags; + } +}); diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/DatasetSelect.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/DatasetSelect.tsx index d089e98a675..7d1d13bdedf 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/DatasetSelect.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/DatasetSelect.tsx @@ -20,6 +20,8 @@ import { useCallback, useMemo, ReactNode } from 'react'; import rison from 'rison'; import { t } from '@apache-superset/core/translation'; import { + isFeatureEnabled, + FeatureFlag, JsonResponse, ClientErrorObject, getClientErrorObject, @@ -37,8 +39,12 @@ import { } from 'src/features/semanticLayers/label'; interface DatasetSelectProps { - onChange: (value: { label: string | ReactNode; value: number }) => void; - value?: { label: string | ReactNode; value: number }; + onChange: (value: { + label: string | ReactNode; + value: number; + kind?: string; + }) => void; + value?: { label: string | ReactNode; value: number; kind?: string }; excludeDatasetIds?: number[]; } @@ -50,37 +56,78 @@ const getErrorMessage = ({ error, message }: ClientErrorObject) => { return errorText; }; +/** + * Builds a unique select-option value for the combined datasource endpoint. + * Datasets and semantic views have independent integer ID sequences, so we + * prefix with a type tag to avoid collisions in AsyncSelect's dedup logic. + */ +const toCompositeValue = (id: number, kind?: string): string => + kind === 'semantic_view' ? `sv:${id}` : `ds:${id}`; + +/** Extracts the numeric ID from a composite "sv:123" / "ds:456" string. */ +const fromCompositeValue = (compositeValue: string | number): number => + typeof compositeValue === 'string' + ? parseInt(compositeValue.split(':')[1], 10) + : compositeValue; + +/** Derives the `kind` value from a composite string prefix. */ +const kindFromComposite = (compositeValue: string): string | undefined => + compositeValue.startsWith('sv:') ? 'semantic_view' : undefined; + +const isExcludedDatasource = ( + item: Dataset, + excludeDatasetIds: number[], +): boolean => { + if (!excludeDatasetIds.includes(item.id)) { + return false; + } + + return item.kind !== 'semantic_view'; +}; + export const loadDatasetOptions = async ( search: string, page: number, pageSize: number, excludeDatasetIds: number[] = [], ) => { + const useSemanticLayers = isFeatureEnabled(FeatureFlag.SemanticLayers); const query = rison.encode({ - columns: ['id', 'table_name', 'database.database_name', 'schema'], + ...(useSemanticLayers + ? {} + : { + columns: ['id', 'table_name', 'database.database_name', 'schema'], + }), filters: [{ col: 'table_name', opr: 'ct', value: search }], page, page_size: pageSize, order_column: 'table_name', order_direction: 'asc', }); + const endpoint = useSemanticLayers + ? `/api/v1/datasource/?q=${query}` + : `/api/v1/dataset/?q=${query}`; return cachedSupersetGet({ - endpoint: `/api/v1/dataset/?q=${query}`, + endpoint, }) .then((response: JsonResponse) => { const filteredResult = response.json.result.filter( - (item: Dataset) => !excludeDatasetIds.includes(item.id), + (item: Dataset) => !isExcludedDatasource(item, excludeDatasetIds), ); const list: { label: string | ReactNode; value: string | number; table_name: string; + kind?: string; }[] = filteredResult.map((item: Dataset) => ({ ...item, label: DatasetSelectLabel(item), - value: item.id, + value: useSemanticLayers + ? toCompositeValue(item.id, item.kind) + : item.id, table_name: item.table_name, + kind: item.kind, })); return { data: list, @@ -98,18 +145,54 @@ const DatasetSelect = ({ value, excludeDatasetIds = [], }: DatasetSelectProps) => { + const useSemanticLayers = isFeatureEnabled(FeatureFlag.SemanticLayers); + const loadDatasetOptionsCallback = useCallback( (search: string, page: number, pageSize: number) => loadDatasetOptions(search, page, pageSize, excludeDatasetIds), [excludeDatasetIds], ); + // Convert the external numeric value to the composite string format that + // AsyncSelect needs for matching against the loaded options. + const selectValue = useMemo(() => { + if (!value || !useSemanticLayers) return value; + return { + ...value, + value: toCompositeValue(value.value, value.kind), + }; + }, [value, useSemanticLayers]); + + // Convert the composite string value from the selected option back to a + // numeric ID before passing it to the external onChange handler. + // AsyncSelect's first argument is a LabeledValue ({key, label, value}) and + // does NOT include custom option fields like `kind`. We derive `kind` from + // the composite value prefix so consumers can distinguish datasource types. + const handleChange = useCallback( + (selected: { + label: string | ReactNode; + value: number | string; + kind?: string; + }) => { + if (typeof selected.value === 'string') { + onChange({ + ...selected, + value: fromCompositeValue(selected.value), + kind: kindFromComposite(selected.value), + }); + } else { + onChange(selected as Parameters<typeof onChange>[0]); + } + }, + [onChange], + ); + return ( <AsyncSelect ariaLabel={datasetLabel()} - value={value} + value={selectValue} options={loadDatasetOptionsCallback} - onChange={onChange} + onChange={useSemanticLayers ? handleChange : onChange} optionFilterProps={['table_name']} notFoundContent={t('No compatible %s found', datasetsLabelLower())} placeholder={t('Select a %s', datasetLabelLower())} diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.tsx index 3d28036785e..301d12df571 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.tsx @@ -113,6 +113,8 @@ import { setNativeFilterFieldValues, shouldShowTimeRangePicker, useForceUpdate, + mapSemanticTypeToGenericDataType, + doesChartMatchFilterDatasource, } from './utils'; import { CHART_CUSTOMIZATION_SUPPORTED_TYPES, @@ -407,6 +409,18 @@ const FiltersConfigForm = ( const datasetId = getDatasetId(); + const getDatasourceType = (): string => { + if (formFilter?.datasourceType) { + return formFilter.datasourceType; + } + if (isChartCustomization) { + return customizationToEdit?.targets?.[0]?.datasourceType || 'table'; + } + return filterToEdit?.targets?.[0]?.datasourceType || 'table'; + }; + + const datasourceType = getDatasourceType(); + const formChanged = useCallback(() => { form.setFields([ { @@ -426,6 +440,7 @@ const FiltersConfigForm = ( ? getControlItemsMap({ expanded, datasetId, + datasourceType, disabled: false, forceUpdate, formChanged, @@ -497,6 +512,7 @@ const FiltersConfigForm = ( } const formData = getFormData({ datasetId, + datasourceType, dashboardId, groupby: formFilter?.column, ...formFilter, @@ -567,6 +583,7 @@ const FiltersConfigForm = ( const newFormData = getFormData({ datasetId, + datasourceType, groupby: hasColumn ? formFilter?.column : undefined, ...formFilter, }); @@ -743,45 +760,93 @@ const FiltersConfigForm = ( useEffect(() => { if (datasetId) { - cachedSupersetGet({ - endpoint: `/api/v1/dataset/${datasetId}?q=${rison.encode({ - columns: [ - 'columns.column_name', - 'columns.expression', - 'columns.filterable', - 'columns.is_dttm', - 'columns.type', - 'columns.type_generic', - 'columns.verbose_name', - 'database.id', - 'database.database_name', - 'datasource_type', - 'filter_select_enabled', - 'id', - 'is_sqllab_view', - 'main_dttm_col', - 'metrics.metric_name', - 'metrics.verbose_name', - 'schema', - 'sql', - 'table_name', - 'time_grain_sqla', - ], - })}`, - }) - .then((response: JsonResponse) => { - setMetrics(response.json?.result?.metrics); - const dataset = response.json?.result; - // modify the response to fit structure expected by AdhocFilterControl - dataset.type = dataset.datasource_type; - dataset.filter_select = true; - setDatasetDetails(dataset); + if (datasourceType === 'semantic_view') { + cachedSupersetGet({ + endpoint: `/api/v1/semantic_view/${datasetId}/structure`, }) - .catch((response: SupersetApiError) => { - addDangerToast(response.message); - }); + .then((response: JsonResponse) => { + const { + name: svName, + dimensions = [], + metrics: svMetrics = [], + } = response.json?.result ?? {}; + const columns = dimensions.map( + (dim: { name: string; type: string }) => { + const mappedType = mapSemanticTypeToGenericDataType(dim.type); + return { + column_name: dim.name, + type: dim.type, + is_dttm: mappedType === GenericDataType.Temporal, + filterable: true, + type_generic: mappedType, + }; + }, + ); + const mappedMetrics = svMetrics.map( + (m: { name: string; definition: string }) => ({ + metric_name: m.name, + expression: m.definition, + verbose_name: null, + }), + ); + setMetrics(mappedMetrics); + setDatasetDetails({ + columns, + metrics: mappedMetrics, + datasource_type: 'semantic_view', + type: 'semantic_view', + 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); + }); + } else { + cachedSupersetGet({ + endpoint: `/api/v1/dataset/${datasetId}?q=${rison.encode({ + columns: [ + 'columns.column_name', + 'columns.expression', + 'columns.filterable', + 'columns.is_dttm', + 'columns.type', + 'columns.type_generic', + 'columns.verbose_name', + 'database.id', + 'database.database_name', + 'datasource_type', + 'filter_select_enabled', + 'id', + 'is_sqllab_view', + 'main_dttm_col', + 'metrics.metric_name', + 'metrics.verbose_name', + 'schema', + 'sql', + 'table_name', + 'time_grain_sqla', + ], + })}`, + }) + .then((response: JsonResponse) => { + setMetrics(response.json?.result?.metrics); + const dataset = response.json?.result; + // modify the response to fit structure expected by AdhocFilterControl + dataset.type = dataset.datasource_type; + dataset.filter_select = true; + setDatasetDetails(dataset); + }) + .catch((response: SupersetApiError) => { + addDangerToast(response.message); + }); + } } - }, [datasetId]); + }, [datasetId, datasourceType]); useImperativeHandle(ref, () => ({ changeTab(tab: 'configuration' | 'scoping') { @@ -820,7 +885,15 @@ const FiltersConfigForm = ( if (chartDatasetUid === undefined) { return; } - if (loadedDatasets[chartDatasetUid]?.id !== formFilter?.dataset?.value) { + + const matchesFilterDatasource = doesChartMatchFilterDatasource( + chartDatasetUid, + loadedDatasets, + formFilter.dataset.value, + datasourceType, + ); + + if (!matchesFilterDatasource) { excluded.push(chart.id); } }); @@ -828,6 +901,7 @@ const FiltersConfigForm = ( }, [ JSON.stringify(Object.values(charts).map(chart => chart.id)), formFilter?.dataset?.value, + datasourceType, JSON.stringify(loadedDatasets), ]); @@ -876,6 +950,7 @@ const FiltersConfigForm = ( filterId={filterId} filterValues={(column: Column) => !!column.is_dttm} datasetId={datasetId} + datasourceType={datasourceType} onChange={column => { // We need reset default value when column changed setNativeFilterFieldValues(form, filterId, { @@ -1064,16 +1139,23 @@ const FiltersConfigForm = ( initialValue={ datasetDetails ? { - label: DatasetSelectLabel({ - id: datasetDetails.id, - table_name: datasetDetails.table_name, - schema: datasetDetails.schema, - database: { - database_name: - datasetDetails.database.database_name, - }, - }), + label: datasetDetails.database + ? DatasetSelectLabel({ + id: datasetDetails.id, + table_name: datasetDetails.table_name, + schema: datasetDetails.schema, + database: { + database_name: + datasetDetails.database.database_name, + }, + }) + : (datasetDetails.table_name ?? + datasetDetails.id), value: datasetDetails.id, + kind: + datasourceType === 'semantic_view' + ? 'semantic_view' + : undefined, } : undefined } @@ -1092,11 +1174,20 @@ const FiltersConfigForm = ( onChange={(value: { label: string | React.ReactNode; value: number; + kind?: string; }) => { - if (value.value !== datasetId) { + const newDatasourceType = + value.kind === 'semantic_view' + ? 'semantic_view' + : 'table'; + if ( + value.value !== datasetId || + newDatasourceType !== datasourceType + ) { setNativeFilterFieldValues(form, filterId, { dataset: value, datasetInfo: value, + datasourceType: newDatasourceType, defaultDataMask: null, column: null, }); @@ -1652,6 +1743,11 @@ const FiltersConfigForm = ( : NativeFilterType.NativeFilter } /> + <FormItem + name={['filters', filterId, 'datasourceType']} + hidden + initialValue={datasourceType} + /> <FormItem name={[ 'filters', diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/getControlItemsMap.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/getControlItemsMap.tsx index 7b8f9577723..83273764460 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/getControlItemsMap.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/getControlItemsMap.tsx @@ -48,6 +48,7 @@ import { ColumnSelect } from './ColumnSelect'; export interface ControlItemsProps { expanded: boolean; datasetId: number; + datasourceType?: string; disabled: boolean; forceUpdate: Function; formChanged: Function; @@ -67,6 +68,7 @@ const CleanFormItem = styled(FormItem)` export default function getControlItemsMap({ expanded, datasetId, + datasourceType, disabled, forceUpdate, formChanged, @@ -139,6 +141,7 @@ export default function getControlItemsMap({ form={form} filterId={filterId} datasetId={datasetId} + datasourceType={datasourceType} filterValues={column => doesColumnMatchFilterType( formFilter?.filterType || '', diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/utils.test.ts b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/utils.test.ts index 6bfbb52a80d..a616c7c1a39 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/utils.test.ts +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/utils.test.ts @@ -31,6 +31,8 @@ import { mostUsedDataset, doesColumnMatchFilterType, getTimeGrainOptions, + mapSemanticTypeToGenericDataType, + doesChartMatchFilterDatasource, } from './utils'; // Test hasTemporalColumns - validates time range pre-filter visibility logic @@ -303,3 +305,75 @@ test('getTimeGrainOptions falls back to value when tuple label is empty', () => { value: 'P1W', label: 'Week' }, ]); }); + +test('mapSemanticTypeToGenericDataType maps numeric semantic types', () => { + expect(mapSemanticTypeToGenericDataType('int64')).toBe( + GenericDataType.Numeric, + ); + expect(mapSemanticTypeToGenericDataType('decimal128(10,2)')).toBe( + GenericDataType.Numeric, + ); +}); + +test('mapSemanticTypeToGenericDataType maps temporal semantic types', () => { + expect(mapSemanticTypeToGenericDataType('timestamp[ms]')).toBe( + GenericDataType.Temporal, + ); + expect(mapSemanticTypeToGenericDataType('date32[day]')).toBe( + GenericDataType.Temporal, + ); +}); + +test('mapSemanticTypeToGenericDataType maps string and boolean semantic types', () => { + expect(mapSemanticTypeToGenericDataType('string')).toBe( + GenericDataType.String, + ); + expect(mapSemanticTypeToGenericDataType('bool')).toBe( + GenericDataType.Boolean, + ); +}); + +test('mapSemanticTypeToGenericDataType returns undefined for unknown types', () => { + expect(mapSemanticTypeToGenericDataType('struct<a:int64>')).toBeUndefined(); + expect(mapSemanticTypeToGenericDataType(undefined)).toBeUndefined(); +}); + +test('doesChartMatchFilterDatasource requires matching datasource type for equal IDs', () => { + const loadedDatasets = { + '7__table': { id: 7, datasource_type: 'table' }, + '7__semantic_view': { id: 7, datasource_type: 'semantic_view' }, + } as unknown as DatasourcesState; + + expect( + doesChartMatchFilterDatasource( + '7__table', + loadedDatasets, + 7, + 'semantic_view', + ), + ).toBe(false); + expect( + doesChartMatchFilterDatasource( + '7__semantic_view', + loadedDatasets, + 7, + 'semantic_view', + ), + ).toBe(true); +}); + +test('doesChartMatchFilterDatasource falls back to datasource UID parsing', () => { + const loadedDatasets = {} as DatasourcesState; + + expect( + doesChartMatchFilterDatasource('7__semantic_view', loadedDatasets, 7), + ).toBe(false); + expect( + doesChartMatchFilterDatasource( + '7__semantic_view', + loadedDatasets, + 7, + 'semantic_view', + ), + ).toBe(true); +}); diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/utils.ts b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/utils.ts index f586dcb5024..0ca302a9ff7 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/utils.ts +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/utils.ts @@ -101,6 +101,50 @@ export const doesColumnMatchFilterType = (filterType: string, column: Column) => filterType as keyof typeof FILTER_SUPPORTED_TYPES ]?.includes(column.type_generic); +export const mapSemanticTypeToGenericDataType = ( + semanticType?: string | null, +): GenericDataType | undefined => { + if (!semanticType) { + return undefined; + } + + const normalized = semanticType.toLowerCase(); + + if ( + /^(struct|list|map|array|fixed_size_list|large_list|union|dictionary)\b/.test( + normalized, + ) + ) { + return undefined; + } + + if (normalized.includes('bool')) { + return GenericDataType.Boolean; + } + + if (/(date|time|timestamp|datetime)/.test(normalized)) { + return GenericDataType.Temporal; + } + + if ( + /(\b(u?int\d*)\b|\bfloat\d*\b|\bdouble\b|\bdecimal\d*\b|\bnumber\b)/.test( + normalized, + ) + ) { + return GenericDataType.Numeric; + } + + if ( + /(\bstr(ing)?\b|\butf8\b|\blarge_string\b|\bbinary\b|\bjson\b|\buuid\b)/.test( + normalized, + ) + ) { + return GenericDataType.String; + } + + return undefined; +}; + // Validates that a filter default value is present when the default value option is enabled. // For range filters, at least one of the two values must be non-null. // For other filters (e.g., filter_select), the value must be non-empty. @@ -144,3 +188,49 @@ export const mostUsedDataset = ( return datasets[mostUsedDataset]?.id; }; + +const normalizeDatasourceType = (datasourceType?: string) => + datasourceType || 'table'; + +const parseDatasourceUid = ( + datasourceUid?: string, +): { id?: number; type?: string } => { + if (!datasourceUid) { + return {}; + } + + const [rawId, type] = String(datasourceUid).split('__'); + const id = Number(rawId); + if (Number.isNaN(id)) { + return {}; + } + + return { id, type }; +}; + +export const doesChartMatchFilterDatasource = ( + chartDatasourceUid: string | undefined, + loadedDatasets: DatasourcesState, + filterDatasetId: number, + filterDatasourceType?: string, +): boolean => { + const expectedType = normalizeDatasourceType(filterDatasourceType); + const loadedDataset = chartDatasourceUid + ? loadedDatasets[chartDatasourceUid] + : undefined; + + if (loadedDataset) { + const loadedType = normalizeDatasourceType( + (loadedDataset as unknown as { datasource_type?: string }) + .datasource_type || loadedDataset.type, + ); + + return loadedDataset.id === filterDatasetId && loadedType === expectedType; + } + + const parsed = parseDatasourceUid(chartDatasourceUid); + return ( + parsed.id === filterDatasetId && + normalizeDatasourceType(parsed.type) === expectedType + ); +}; diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/transformers/customizationTransformer.ts b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/transformers/customizationTransformer.ts index 2e0eaf3bb49..e32ef05724a 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/transformers/customizationTransformer.ts +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/transformers/customizationTransformer.ts @@ -92,6 +92,10 @@ function buildCustomizationTarget( target.datasetId = formInputs.dataset.value; } + if (formInputs.datasourceType) { + target.datasourceType = formInputs.datasourceType; + } + if (formInputs.dataset && formInputs.column) { target.column = { name: formInputs.column }; } diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/transformers/filterTransformer.ts b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/transformers/filterTransformer.ts index 575d6baebe9..691958244ea 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/transformers/filterTransformer.ts +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/transformers/filterTransformer.ts @@ -97,6 +97,10 @@ function buildFilterTarget( : formInputs.dataset; } + if (formInputs.datasourceType) { + target.datasourceType = formInputs.datasourceType; + } + if (formInputs.dataset && formInputs.column) { target.column = { name: formInputs.column }; } diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/types.ts b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/types.ts index 78461fcbf20..1e0c1b0914a 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/types.ts +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/types.ts @@ -54,6 +54,7 @@ export interface NativeFiltersFormItem { time_grains?: string[]; type: typeof NativeFilterType.NativeFilter; description: string; + datasourceType?: string; } export interface NativeFilterDivider { id: string; @@ -91,6 +92,7 @@ export interface ChartCustomizationsFormItem { granularity_sqla?: string; type: typeof NativeFilterType.NativeFilter; description: string; + datasourceType?: string; datasetInfo?: { label: string | ReactNode; value: number; diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/utils.ts b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/utils.ts index 9b3b87641cf..de2ef59c84b 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/utils.ts +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/utils.ts @@ -130,6 +130,9 @@ export const createHandleSave = if (formInputs.dataset) { target.datasetId = formInputs.dataset.value; } + if (formInputs.datasourceType) { + target.datasourceType = formInputs.datasourceType; + } if (formInputs.dataset && formInputs.column) { target.column = { name: formInputs.column }; } diff --git a/superset-frontend/src/dashboard/components/nativeFilters/utils.ts b/superset-frontend/src/dashboard/components/nativeFilters/utils.ts index 4401071eb8f..0044d35f7b2 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/utils.ts +++ b/superset-frontend/src/dashboard/components/nativeFilters/utils.ts @@ -46,6 +46,7 @@ const getDefaultRowLimit = (): number => { export const getFormData = ({ datasetId, + datasourceType, dependencies = {}, groupby, defaultDataMask, @@ -62,6 +63,7 @@ export const getFormData = ({ }: (Partial<Filter> | Partial<ChartCustomization>) & { dashboardId: number; datasetId?: number; + datasourceType?: string; dependencies?: object; groupby?: string; adhoc_filters?: AdhocFilter[]; @@ -76,7 +78,8 @@ export const getFormData = ({ sortMetric?: string; } = {}; if (datasetId) { - otherProps.datasource = `${datasetId}__table`; + const dsType = datasourceType || 'table'; + otherProps.datasource = `${datasetId}__${dsType}`; } if (groupby) { otherProps.groupby = [groupby]; diff --git a/superset-frontend/src/dashboard/reducers/dashboardInfo.test.ts b/superset-frontend/src/dashboard/reducers/dashboardInfo.test.ts index 8bf3e5b774d..b35d3e70a27 100644 --- a/superset-frontend/src/dashboard/reducers/dashboardInfo.test.ts +++ b/superset-frontend/src/dashboard/reducers/dashboardInfo.test.ts @@ -258,7 +258,13 @@ test('CLEAR_ALL_CHART_CUSTOMIZATIONS filters out null entries before mapping', ( null, { id: 'CUSTOM-1', - targets: [{ datasetId: 1, column: { name: 'status' } }], + targets: [ + { + datasetId: 1, + datasourceType: 'semantic_view', + column: { name: 'status' }, + }, + ], chartsInScope: [10], }, null, @@ -272,7 +278,9 @@ test('CLEAR_ALL_CHART_CUSTOMIZATIONS filters out null entries before mapping', ( const config = result.metadata?.chart_customization_config; expect(config).toHaveLength(1); expect(config![0].id).toBe('CUSTOM-1'); - expect(config![0].targets).toEqual([{ datasetId: 1 }]); + expect(config![0].targets).toEqual([ + { datasetId: 1, datasourceType: 'semantic_view' }, + ]); }); test('CLEAR_ALL_CHART_CUSTOMIZATIONS filters out undefined entries before mapping', () => { @@ -283,7 +291,13 @@ test('CLEAR_ALL_CHART_CUSTOMIZATIONS filters out undefined entries before mappin undefined, { id: 'CUSTOM-1', - targets: [{ datasetId: 1, column: { name: 'status' } }], + targets: [ + { + datasetId: 1, + datasourceType: 'semantic_view', + column: { name: 'status' }, + }, + ], chartsInScope: [10], }, undefined, @@ -297,5 +311,7 @@ test('CLEAR_ALL_CHART_CUSTOMIZATIONS filters out undefined entries before mappin const config = result.metadata?.chart_customization_config; expect(config).toHaveLength(1); expect(config![0].id).toBe('CUSTOM-1'); - expect(config![0].targets).toEqual([{ datasetId: 1 }]); + expect(config![0].targets).toEqual([ + { datasetId: 1, datasourceType: 'semantic_view' }, + ]); }); diff --git a/superset-frontend/src/dashboard/reducers/dashboardInfo.ts b/superset-frontend/src/dashboard/reducers/dashboardInfo.ts index 26c919f01b3..d2decf48a3f 100644 --- a/superset-frontend/src/dashboard/reducers/dashboardInfo.ts +++ b/superset-frontend/src/dashboard/reducers/dashboardInfo.ts @@ -306,6 +306,7 @@ export default function dashboardInfoReducer( ...customization, targets: customization.targets?.map(target => ({ datasetId: target.datasetId, + datasourceType: target.datasourceType, })), }), ), diff --git a/superset-frontend/src/features/datasets/DatasetSelectLabel/index.tsx b/superset-frontend/src/features/datasets/DatasetSelectLabel/index.tsx index e1a0c957a5a..f93acd5391e 100644 --- a/superset-frontend/src/features/datasets/DatasetSelectLabel/index.tsx +++ b/superset-frontend/src/features/datasets/DatasetSelectLabel/index.tsx @@ -28,6 +28,7 @@ export type Dataset = { id: number; table_name: string; datasource_type?: string; + kind?: string; schema: string; database: Database; }; diff --git a/superset/semantic_layers/api.py b/superset/semantic_layers/api.py index bcb855581db..a5fc9e49d4d 100644 --- a/superset/semantic_layers/api.py +++ b/superset/semantic_layers/api.py @@ -260,7 +260,14 @@ class SemanticViewRestApi(BaseSupersetModelRestApi): ) return self.response_422(message=str(ex)) - return self.response(200, result={"dimensions": dimensions, "metrics": metrics}) + return self.response( + 200, + result={ + "name": view.name, + "dimensions": dimensions, + "metrics": metrics, + }, + ) @expose("/", methods=("POST",)) @protect() diff --git a/superset/semantic_layers/mapper.py b/superset/semantic_layers/mapper.py index b71dfeb44db..cd6cbbf58a8 100644 --- a/superset/semantic_layers/mapper.py +++ b/superset/semantic_layers/mapper.py @@ -530,11 +530,11 @@ def _convert_query_object_filter( dimension = all_dimensions[col] val_str = filter_["val"] - value: FilterValues | frozenset[FilterValues] + value: FilterValues | tuple[FilterValues, ...] if val_str is None: value = None elif isinstance(val_str, (list, tuple)): - value = frozenset(val_str) + value = tuple(val_str) else: value = val_str @@ -576,6 +576,8 @@ def _convert_query_object_filter( FilterOperator.LESS_THAN_OR_EQUALS.value: Operator.LESS_THAN_OR_EQUAL, FilterOperator.IN.value: Operator.IN, FilterOperator.NOT_IN.value: Operator.NOT_IN, + FilterOperator.ILIKE.value: Operator.LIKE, + FilterOperator.NOT_ILIKE.value: Operator.NOT_LIKE, FilterOperator.LIKE.value: Operator.LIKE, FilterOperator.NOT_LIKE.value: Operator.NOT_LIKE, FilterOperator.IS_NULL.value: Operator.IS_NULL, diff --git a/tests/unit_tests/semantic_layers/mapper_test.py b/tests/unit_tests/semantic_layers/mapper_test.py index 0cbb59c8430..469ad83e670 100644 --- a/tests/unit_tests/semantic_layers/mapper_test.py +++ b/tests/unit_tests/semantic_layers/mapper_test.py @@ -368,7 +368,32 @@ def test_convert_query_object_filter_in(mock_datasource: MagicMock) -> None: type=PredicateType.WHERE, column=all_dimensions["category"], operator=Operator.IN, - value=frozenset({"Electronics", "Books"}), + value=("Electronics", "Books"), + ) + } + + +def test_convert_query_object_filter_ilike(mock_datasource: MagicMock) -> None: + """ + Test conversion of ILIKE filter. + """ + all_dimensions = { + dim.name: dim for dim in mock_datasource.implementation.dimensions + } + filter_: ValidatedQueryObjectFilterClause = { + "op": FilterOperator.ILIKE.value, + "col": "category", + "val": "%book%", + } + + result = _convert_query_object_filter(filter_, all_dimensions) + + assert result == { + Filter( + type=PredicateType.WHERE, + column=all_dimensions["category"], + operator=Operator.LIKE, + value="%book%", ) }
