feat(semantic layers): dashboard filters (#40475)

This commit is contained in:
Beto Dealmeida
2026-07-10 12:18:51 -07:00
committed by GitHub
parent a6ce2fd76b
commit 5b8e94de26
30 changed files with 917 additions and 138 deletions
@@ -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):
@@ -18,6 +18,7 @@
*/
import { AdhocFilter, DataMask } from '@superset-ui/core';
import { DatasourceType } from './Datasource';
export interface ColumnOption {
label: string;
@@ -38,6 +39,7 @@ export interface NativeFilterScope {
export interface NativeFilterTarget {
datasetId: number;
column: NativeFilterColumn;
datasourceType?: DatasourceType;
// maybe someday support this?
// show values from these columns in the filter options selector
@@ -31,6 +31,7 @@ import {
ChartDataResponseResult,
Behavior,
DataMask,
DatasourceType,
isFeatureEnabled,
FeatureFlag,
getChartMetadataRegistry,
@@ -147,8 +148,13 @@ const FilterValue: FC<FilterValueProps> = ({
const [target] = targets || [];
const {
datasetId,
datasourceType,
column = {},
}: Partial<{ datasetId: number; column: { name?: string } }> = target || {};
}: Partial<{
datasetId: number;
datasourceType: DatasourceType;
column: { name?: string };
}> = target || {};
const groupby = column?.name;
const hasDataSource = !!datasetId;
const [isLoading, setIsLoading] = useState<boolean>(hasDataSource);
@@ -182,6 +188,7 @@ const FilterValue: FC<FilterValueProps> = ({
const newFormData = getFormData({
...filter,
datasetId,
datasourceType,
dependencies,
groupby,
adhoc_filters: adhocFilters,
@@ -19,9 +19,12 @@
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,
DatasourceType,
ensureIsArray,
JsonResponse,
useChangeEffect,
getClientErrorObject,
} from '@superset-ui/core';
@@ -29,6 +32,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 +41,7 @@ interface ColumnSelectProps {
formField?: keyof NativeFiltersFormItem;
filterId: string;
datasetId?: number;
datasourceType?: DatasourceType;
value?: string | string[];
onChange?: (value: string) => void;
mode?: 'multiple';
@@ -51,6 +56,7 @@ export function ColumnSelect({
formField = 'column',
filterId,
datasetId,
datasourceType,
value,
onChange,
mode,
@@ -86,25 +92,69 @@ 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 || 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 === DatasourceType.SemanticView) {
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,
type: dim.type,
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 +163,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));
}
}
});
@@ -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;
}
});
@@ -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,81 @@ 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 => {
if (typeof compositeValue !== 'string') return compositeValue;
const parts = compositeValue.split(':');
return parts.length === 2
? parseInt(parts[1], 10)
: parseInt(compositeValue, 10);
};
/** 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 +148,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())}
@@ -23,6 +23,7 @@ import {
Behavior,
ChartDataResponseResult,
Column,
DatasourceType,
isFeatureEnabled,
FeatureFlag,
Filter,
@@ -113,6 +114,8 @@ import {
setNativeFilterFieldValues,
shouldShowTimeRangePicker,
useForceUpdate,
mapSemanticTypeToGenericDataType,
doesChartMatchFilterDatasource,
} from './utils';
import {
CHART_CUSTOMIZATION_SUPPORTED_TYPES,
@@ -406,6 +409,21 @@ const FiltersConfigForm = (
const datasetId = getDatasetId();
const getDatasourceType = (): DatasourceType => {
if (formFilter?.datasourceType) {
return formFilter.datasourceType;
}
if (isChartCustomization) {
return (
customizationToEdit?.targets?.[0]?.datasourceType ||
DatasourceType.Table
);
}
return filterToEdit?.targets?.[0]?.datasourceType || DatasourceType.Table;
};
const datasourceType = getDatasourceType();
const formChanged = useCallback(() => {
form.setFields([
{
@@ -425,6 +443,7 @@ const FiltersConfigForm = (
? getControlItemsMap({
expanded,
datasetId,
datasourceType,
disabled: false,
forceUpdate,
formChanged,
@@ -496,6 +515,7 @@ const FiltersConfigForm = (
}
const formData = getFormData({
datasetId,
datasourceType,
dashboardId,
groupby: formFilter?.column,
...formFilter,
@@ -566,6 +586,7 @@ const FiltersConfigForm = (
const newFormData = getFormData({
datasetId,
datasourceType,
groupby: hasColumn ? formFilter?.column : undefined,
...formFilter,
});
@@ -741,45 +762,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 === DatasourceType.SemanticView) {
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: 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);
});
} 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') {
@@ -818,7 +887,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);
}
});
@@ -826,6 +903,7 @@ const FiltersConfigForm = (
}, [
JSON.stringify(Object.values(charts).map(chart => chart.id)),
formFilter?.dataset?.value,
datasourceType,
JSON.stringify(loadedDatasets),
]);
@@ -874,6 +952,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, {
@@ -1062,16 +1141,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 === DatasourceType.SemanticView
? 'semantic_view'
: undefined,
}
: undefined
}
@@ -1090,11 +1176,20 @@ const FiltersConfigForm = (
onChange={(value: {
label: string | React.ReactNode;
value: number;
kind?: string;
}) => {
if (value.value !== datasetId) {
const newDatasourceType =
value.kind === 'semantic_view'
? DatasourceType.SemanticView
: DatasourceType.Table;
if (
value.value !== datasetId ||
newDatasourceType !== datasourceType
) {
setNativeFilterFieldValues(form, filterId, {
dataset: value,
datasetInfo: value,
datasourceType: newDatasourceType,
defaultDataMask: null,
column: null,
});
@@ -1650,6 +1745,11 @@ const FiltersConfigForm = (
: NativeFilterType.NativeFilter
}
/>
<FormItem
name={['filters', filterId, 'datasourceType']}
hidden
initialValue={datasourceType}
/>
<FormItem
name={[
'filters',
@@ -29,6 +29,7 @@ import { t } from '@apache-superset/core/translation';
import {
Filter,
ChartCustomization,
DatasourceType,
getChartControlPanelRegistry,
} from '@superset-ui/core';
import { styled } from '@apache-superset/core/theme';
@@ -48,6 +49,7 @@ import { ColumnSelect } from './ColumnSelect';
export interface ControlItemsProps {
expanded: boolean;
datasetId: number;
datasourceType?: DatasourceType;
disabled: boolean;
forceUpdate: Function;
formChanged: Function;
@@ -67,6 +69,7 @@ const CleanFormItem = styled(FormItem)`
export default function getControlItemsMap({
expanded,
datasetId,
datasourceType,
disabled,
forceUpdate,
formChanged,
@@ -139,6 +142,7 @@ export default function getControlItemsMap({
form={form}
filterId={filterId}
datasetId={datasetId}
datasourceType={datasourceType}
filterValues={column =>
doesColumnMatchFilterType(
formFilter?.filterType || '',
@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { Column } from '@superset-ui/core';
import { Column, DatasourceType } from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/common';
import {
ChartsState,
@@ -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: DatasourceType.Table },
'7__semantic_view': { id: 7, datasource_type: DatasourceType.SemanticView },
} as unknown as DatasourcesState;
expect(
doesChartMatchFilterDatasource(
'7__table',
loadedDatasets,
7,
DatasourceType.SemanticView,
),
).toBe(false);
expect(
doesChartMatchFilterDatasource(
'7__semantic_view',
loadedDatasets,
7,
DatasourceType.SemanticView,
),
).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,
DatasourceType.SemanticView,
),
).toBe(true);
});
@@ -20,7 +20,7 @@ import { flatMapDeep } from 'lodash-es';
import type { FormInstance } from '@superset-ui/core/components';
import { useState, useCallback } from 'react';
import { CustomControlItem, Dataset } from '@superset-ui/chart-controls';
import { Column, ensureIsArray } from '@superset-ui/core';
import { Column, DatasourceType, ensureIsArray } from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/common';
import { DatasourcesState, ChartsState } from 'src/dashboard/types';
import { FILTER_SUPPORTED_TYPES } from './constants';
@@ -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,48 @@ export const mostUsedDataset = (
return datasets[mostUsedDataset]?.id;
};
const normalizeDatasourceType = (datasourceType?: string): string =>
datasourceType || 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?: DatasourceType,
): boolean => {
const expectedType = normalizeDatasourceType(filterDatasourceType);
const loadedDataset = chartDatasourceUid
? loadedDatasets[chartDatasourceUid]
: undefined;
if (loadedDataset) {
const loadedType = normalizeDatasourceType(
loadedDataset.datasource_type || loadedDataset.type,
);
return loadedDataset.id === filterDatasetId && loadedType === expectedType;
}
const parsed = parseDatasourceUid(chartDatasourceUid);
return (
parsed.id === filterDatasetId &&
normalizeDatasourceType(parsed.type) === expectedType
);
};
@@ -0,0 +1,74 @@
/**
* 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 { DatasourceType } from '@superset-ui/core';
import { buildNativeFilterTarget } from './buildTarget';
test('returns an empty target when no dataset is selected', () => {
expect(buildNativeFilterTarget({})).toEqual({});
});
test('uses dataset.value when dataset is an object', () => {
expect(
buildNativeFilterTarget({
dataset: { value: 7 },
column: 'colA',
}),
).toEqual({ datasetId: 7, column: { name: 'colA' } });
});
test('uses dataset directly when dataset is a primitive number', () => {
// ``filterTransformer`` accepts the legacy primitive shape, so the helper
// has to handle both.
expect(
buildNativeFilterTarget({
dataset: 42,
column: 'colB',
}),
).toEqual({ datasetId: 42, column: { name: 'colB' } });
});
test('includes datasourceType when present', () => {
expect(
buildNativeFilterTarget({
dataset: { value: 1 },
datasourceType: DatasourceType.SemanticView,
column: 'colC',
}),
).toEqual({
datasetId: 1,
datasourceType: DatasourceType.SemanticView,
column: { name: 'colC' },
});
});
test('omits column when no column is selected', () => {
expect(
buildNativeFilterTarget({
dataset: { value: 9 },
}),
).toEqual({ datasetId: 9 });
});
test('omits datasourceType when undefined', () => {
const target = buildNativeFilterTarget({
dataset: { value: 5 },
column: 'colD',
});
expect(target).not.toHaveProperty('datasourceType');
});
@@ -0,0 +1,61 @@
/**
* 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 { DatasourceType, NativeFilterTarget } from '@superset-ui/core';
/**
* Minimal shape of the form inputs that drive ``NativeFilterTarget``
* construction. Native filters, chart customizations, and the modal save
* path all populate these three fields the same way.
*/
export interface TargetFormInputs {
dataset?: { value: number } | number;
datasourceType?: DatasourceType;
column?: string;
}
/**
* Build the ``NativeFilterTarget`` carried by a native filter or chart
* customization from its form inputs.
*
* Consolidates what used to live in three places — ``filterTransformer``,
* ``customizationTransformer``, and ``createHandleSave`` — so changes to the
* target shape only need to happen here.
*/
export function buildNativeFilterTarget(
formInputs: TargetFormInputs,
): Partial<NativeFilterTarget> {
const target: Partial<NativeFilterTarget> = {};
if (formInputs.dataset != null) {
target.datasetId =
typeof formInputs.dataset === 'object'
? formInputs.dataset.value
: formInputs.dataset;
}
if (formInputs.datasourceType) {
target.datasourceType = formInputs.datasourceType;
}
if (formInputs.dataset != null && formInputs.column) {
target.column = { name: formInputs.column };
}
return target;
}
@@ -31,6 +31,7 @@ import {
NativeFiltersFormItem,
NativeFilterDivider,
} from '../types';
import { buildNativeFilterTarget } from './buildTarget';
type CustomizationFormInput =
| ChartCustomizationsFormItem
@@ -82,17 +83,7 @@ function transformCustomizationDivider(
function buildCustomizationTarget(
formInputs: ChartCustomizationsFormItem,
): Partial<NativeFilterTarget> {
const target: Partial<NativeFilterTarget> = {};
if (formInputs.dataset) {
target.datasetId = formInputs.dataset.value;
}
if (formInputs.dataset && formInputs.column) {
target.column = { name: formInputs.column };
}
return target;
return buildNativeFilterTarget(formInputs);
}
function transformFormInput(
@@ -32,6 +32,7 @@ import {
NativeFilterDivider,
ChartCustomizationsFormItem,
} from '../types';
import { buildNativeFilterTarget } from './buildTarget';
type FilterFormInput =
| NativeFiltersFormItem
@@ -86,20 +87,7 @@ function transformDivider(
function buildFilterTarget(
formInputs: NativeFiltersFormItem,
): Partial<NativeFilterTarget> {
const target: Partial<NativeFilterTarget> = {};
if (formInputs.dataset) {
target.datasetId =
typeof formInputs.dataset === 'object'
? formInputs.dataset.value
: formInputs.dataset;
}
if (formInputs.dataset && formInputs.column) {
target.column = { name: formInputs.column };
}
return target;
return buildNativeFilterTarget(formInputs);
}
function transformFormInput(
@@ -19,6 +19,7 @@
import {
AdhocFilter,
DataMask,
DatasourceType,
NativeFilterType,
NativeFilterScope,
Filter,
@@ -54,6 +55,7 @@ export interface NativeFiltersFormItem {
time_grains?: string[];
type: typeof NativeFilterType.NativeFilter;
description: string;
datasourceType?: DatasourceType;
}
export interface NativeFilterDivider {
id: string;
@@ -92,6 +94,7 @@ export interface ChartCustomizationsFormItem {
time_grains?: string[];
type: typeof NativeFilterType.NativeFilter;
description: string;
datasourceType?: DatasourceType;
datasetInfo?: {
label: string | ReactNode;
value: number;
@@ -32,6 +32,7 @@ import {
} from '@superset-ui/core';
import { logging } from '@apache-superset/core/utils';
import { DASHBOARD_ROOT_ID } from 'src/dashboard/util/constants';
import { buildNativeFilterTarget } from './transformers/buildTarget';
import {
ChartCustomizationsForm,
FilterChangesType,
@@ -126,13 +127,8 @@ export const createHandleSave =
};
}
const target: Partial<NativeFilterTarget> = {};
if (formInputs.dataset) {
target.datasetId = formInputs.dataset.value;
}
if (formInputs.dataset && formInputs.column) {
target.column = { name: formInputs.column };
}
const target: Partial<NativeFilterTarget> =
buildNativeFilterTarget(formInputs);
return {
id,
@@ -22,6 +22,7 @@ import {
Behavior,
ChartCustomization,
DataMaskStateWithId,
DatasourceType,
EXTRA_FORM_DATA_APPEND_KEYS,
EXTRA_FORM_DATA_OVERRIDE_KEYS,
ExtraFormData,
@@ -46,6 +47,7 @@ const getDefaultRowLimit = (): number => {
export const getFormData = ({
datasetId,
datasourceType,
dependencies = {},
groupby,
defaultDataMask,
@@ -62,6 +64,7 @@ export const getFormData = ({
}: (Partial<Filter> | Partial<ChartCustomization>) & {
dashboardId: number;
datasetId?: number;
datasourceType?: DatasourceType;
dependencies?: object;
groupby?: string;
adhoc_filters?: AdhocFilter[];
@@ -76,7 +79,8 @@ export const getFormData = ({
sortMetric?: string;
} = {};
if (datasetId) {
otherProps.datasource = `${datasetId}__table`;
const dsType = datasourceType || DatasourceType.Table;
otherProps.datasource = `${datasetId}__${dsType}`;
}
if (groupby) {
otherProps.groupby = [groupby];
@@ -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' },
]);
});
@@ -305,6 +305,7 @@ export default function dashboardInfoReducer(
...customization,
targets: customization.targets?.map(target => ({
datasetId: target.datasetId,
datasourceType: target.datasourceType,
})),
}),
),
+3
View File
@@ -229,6 +229,9 @@ export type Datasource = Dataset & {
column_types: GenericDataType[];
table_name: string;
database?: Database;
// Populated by the dashboard datasets API alongside ``type``; declared here
// so callers can rely on structural typing instead of casting.
datasource_type?: DatasourceType;
};
export type DatasourcesState = {
[key: string]: Datasource;
@@ -304,8 +304,14 @@ function processGroupByCustomizations(
return {};
}
// ``form_data.datasource`` is encoded as ``<id>__<type>`` (e.g.
// ``7__table``, ``7__semantic_view``). Datasets and semantic views have
// independent ID spaces, so we have to compare both the numeric ID and the
// datasource type to avoid matching a semantic-view customization to a
// table chart that happens to share its numeric ID.
const chartDatasetParts = String(chartDataset).split('__');
const chartDatasetId = chartDatasetParts[0];
const chartDatasourceType = chartDatasetParts[1];
const matchingCustomizations = chartCustomizationItems.filter(item => {
if (item.removed) return false;
@@ -314,11 +320,20 @@ function processGroupByCustomizations(
if (!targetDataset) return false;
const targetDatasetId = String(targetDataset);
const targetDatasourceType = item.targets?.[0]?.datasourceType;
const datasetMatches = chartDatasetId === targetDatasetId;
// ``datasourceType`` is optional on targets persisted before semantic
// views shipped, so a missing value on either side is treated as a
// wildcard match — this preserves behavior for pre-existing
// customizations while still disambiguating new ones.
const datasourceTypeMatches =
!targetDatasourceType ||
!chartDatasourceType ||
targetDatasourceType === chartDatasourceType;
const chartMatches =
item.chartsInScope == null || item.chartsInScope.includes(chart.id);
return datasetMatches && chartMatches;
return datasetMatches && datasourceTypeMatches && chartMatches;
});
const chartType = chart.form_data?.viz_type;
@@ -548,3 +548,46 @@ test('Scope boundary: display control with chartsInScope:[] does not affect the
const result = getFormDataWithExtraFilters(argsOutOfScope);
expectGroupBy(result, ['original_column']);
});
test('chart customization does not match across datasource ID spaces', () => {
// A customization targeting semantic_view ``3`` must not match a chart
// backed by table ``3``: the two have independent ID spaces.
const customizationId = 'CHART_CUSTOMIZATION-groupby-cross';
const args: GetFormDataWithExtraFiltersArguments = {
...mockArgs,
chart: {
...mockChart,
form_data: {
...mockChart.form_data,
viz_type: 'table',
datasource: '3__table',
groupby: ['original_column'],
},
},
dataMask: {
[customizationId]: {
id: customizationId,
extraFormData: {},
filterState: { value: ['status'] },
ownState: {},
},
},
chartCustomizationItems: [
createChartCustomization({
id: customizationId,
targets: [
{
datasetId: 3,
datasourceType: 'semantic_view' as any,
column: { name: 'status' },
},
],
}),
],
};
// Customization is on semantic_view:3, chart is on table:3 — they
// shouldn't be linked, so the chart's groupby is unchanged.
const result = getFormDataWithExtraFilters(args);
expectGroupBy(result, ['original_column']);
});
@@ -19,10 +19,16 @@
import {
AppliedCrossFilterType,
ChartCustomization,
ChartCustomizationType,
DatasourceType,
Filter,
NativeFilterType,
} from '@superset-ui/core';
import { getRelatedCharts } from './getRelatedCharts';
import {
getRelatedCharts,
getRelatedChartsForChartCustomization,
} from './getRelatedCharts';
const slices = {
'1': { datasource: 'ds1', slice_id: 1 },
@@ -105,3 +111,60 @@ test('Return only chart ids in specific scope with cross filter', () => {
const result = getRelatedCharts('1', filters['1'], slices);
expect(result).toEqual([2]);
});
test('getRelatedChartsForChartCustomization disambiguates by datasource type', () => {
// Tables and semantic views have independent ID spaces, so dataset id ``1``
// can refer to either. The customization here targets semantic view 1 and
// must NOT match the table-1 chart even though their numeric IDs collide.
const mixedSlices = {
'10': { form_data: { datasource: '1__table' }, slice_id: 10 },
'11': { form_data: { datasource: '1__semantic_view' }, slice_id: 11 },
'12': { form_data: { datasource: '2__table' }, slice_id: 12 },
} as any;
const customization = {
id: 'cust1',
type: ChartCustomizationType.ChartCustomization,
name: 'cust',
filterType: 'filter_select',
targets: [
{
datasetId: 1,
datasourceType: DatasourceType.SemanticView,
column: { name: 'col1' },
},
],
scope: { rootPath: [], excluded: [] },
defaultDataMask: {},
controlValues: {},
} as unknown as ChartCustomization;
expect(
getRelatedChartsForChartCustomization(customization, mixedSlices),
).toEqual([11]);
});
test('getRelatedChartsForChartCustomization falls back to ID match when type is absent', () => {
// Legacy customizations persisted before semantic views shipped don't
// carry ``datasourceType``. We keep the ID-only behavior for those rather
// than silently dropping matches.
const mixedSlices = {
'10': { form_data: { datasource: '1__table' }, slice_id: 10 },
'11': { form_data: { datasource: '1__semantic_view' }, slice_id: 11 },
} as any;
const customization = {
id: 'cust1',
type: ChartCustomizationType.ChartCustomization,
name: 'cust',
filterType: 'filter_select',
targets: [{ datasetId: 1, column: { name: 'col1' } }],
scope: { rootPath: [], excluded: [] },
defaultDataMask: {},
controlValues: {},
} as unknown as ChartCustomization;
expect(
getRelatedChartsForChartCustomization(customization, mixedSlices).sort(),
).toEqual([10, 11]);
});
@@ -130,6 +130,10 @@ export function getRelatedChartsForChartCustomization(
}
const targetDatasetId = String(dataset);
// ``datasourceType`` disambiguates between independent ID spaces (e.g.
// table ``1`` vs semantic view ``1``). When absent on either side we fall
// back to ID-only matching so legacy customizations keep working.
const targetDatasourceType = targets?.[0]?.datasourceType;
return Object.values(slices)
.filter(slice => {
@@ -138,8 +142,17 @@ export function getRelatedChartsForChartCustomization(
const sliceDatasetParts = String(sliceDataset).split('__');
const sliceDatasetId = sliceDatasetParts[0];
const sliceDatasourceType = sliceDatasetParts[1];
return sliceDatasetId === targetDatasetId;
if (sliceDatasetId !== targetDatasetId) return false;
if (
targetDatasourceType &&
sliceDatasourceType &&
targetDatasourceType !== sliceDatasourceType
) {
return false;
}
return true;
})
.map(slice => slice.slice_id);
}
@@ -19,6 +19,7 @@
import { Tooltip } from '@superset-ui/core/components';
import { t } from '@apache-superset/core/translation';
import { styled } from '@apache-superset/core/theme';
import { parentLabelLower } from 'src/features/semanticLayers/label';
type Database = {
database_name: string;
@@ -28,8 +29,9 @@ export type Dataset = {
id: number;
table_name: string;
datasource_type?: string;
kind?: string;
schema: string;
database: Database;
database?: Database;
};
const TooltipContent = styled.div`
@@ -103,14 +105,14 @@ export const DatasetSelectLabel = (item: Dataset) => (
</div>
<div className="tooltip-description">
<div>
{t('Database')}: {item.database.database_name}
</div>
<div>
{t('Schema')}:{' '}
{item.schema && isValidValue(item.schema)
? item.schema
: t('Not defined')}
{parentLabelLower(item.kind)}:{' '}
{item.database?.database_name ?? t('Not defined')}
</div>
{item.schema && isValidValue(item.schema) && (
<div>
{t('Schema')}: {item.schema}
</div>
)}
</div>
</TooltipContent>
}
@@ -119,10 +121,12 @@ export const DatasetSelectLabel = (item: Dataset) => (
<StyledLabel>
{item.table_name && isValidValue(item.table_name)
? item.table_name
: item.database.database_name}
: (item.database?.database_name ?? t('Not defined'))}
</StyledLabel>
<StyledDetailWrapper>
<StyledLabelDetail>{item.database.database_name}</StyledLabelDetail>
<StyledLabelDetail>
{item.database?.database_name ?? t('Not defined')}
</StyledLabelDetail>
{item.schema && isValidValue(item.schema) && (
<StyledLabelDetail>&nbsp;- {item.schema}</StyledLabelDetail>
)}
@@ -63,3 +63,15 @@ export const databasesLabel = () => sl(t('Databases'), t('Data connections'));
/** Lower-case plural: "databases" / "data connections" */
export const databasesLabelLower = () =>
sl(t('databases'), t('data connections'));
// ---------------------------------------------------------------------------
// Per-datasource parent label
// ---------------------------------------------------------------------------
/**
* Returns the label for the parent of a datasource: "database" for datasets,
* "semantic layer" for semantic views. Pass the `kind` field from the
* combined datasource list response.
*/
export const parentLabelLower = (kind?: string) =>
kind === 'semantic_view' ? t('semantic layer') : t('database');
+8 -1
View File
@@ -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()
+14 -1
View File
@@ -674,7 +674,20 @@ def _convert_query_object_filter(
value = _coerce_filter_value(value, dimension)
# Map QueryObject operators to semantic layer operators
# Map QueryObject operators to semantic layer operators. The Operator enum
# exposes only LIKE (case-sensitive), so case-insensitive variants are
# rejected up front rather than silently collapsed: doing so leaves the
# actual case handling at the mercy of the semantic backend's collation
# and silently diverges from the operator the dashboard author chose.
if operator_str in {
FilterOperator.ILIKE.value,
FilterOperator.NOT_ILIKE.value,
}:
raise ValueError(
f"Operator {operator_str} (case-insensitive match) is not supported "
"by Semantic Views; use the case-sensitive LIKE/NOT_LIKE instead."
)
operator_mapping = {
FilterOperator.EQUALS.value: Operator.EQUALS,
FilterOperator.NOT_EQUALS.value: Operator.NOT_EQUALS,
@@ -2108,6 +2108,7 @@ def test_get_semantic_view_structure(
mock_metric.description = "Order count"
mock_view = MagicMock()
mock_view.name = "orders"
mock_view.implementation.get_dimensions.return_value = {mock_dim}
mock_view.implementation.get_metrics.return_value = {mock_metric}
@@ -2120,6 +2121,7 @@ def test_get_semantic_view_structure(
assert response.status_code == 200
result = response.json["result"]
assert result["name"] == "orders"
assert len(result["dimensions"]) == 1
assert result["dimensions"][0]["name"] == "order_date"
assert result["dimensions"][0]["type"] == "timestamp[us]"
@@ -2202,6 +2204,7 @@ def test_get_semantic_view_structure_no_grain(
mock_dim.grain = None
mock_view = MagicMock()
mock_view.name = "customers"
mock_view.implementation.get_dimensions.return_value = {mock_dim}
mock_view.implementation.get_metrics.return_value = set()
@@ -375,6 +375,28 @@ def test_convert_query_object_filter_in(mock_datasource: MagicMock) -> None:
}
def test_convert_query_object_filter_ilike_rejected(
mock_datasource: MagicMock,
) -> None:
"""
Case-insensitive operators are rejected explicitly rather than silently
collapsed into LIKE that collapse would let the backend's collation
decide case sensitivity, silently diverging from the filter the dashboard
author selected.
"""
all_dimensions = {
dim.name: dim for dim in mock_datasource.implementation.dimensions
}
for op in (FilterOperator.ILIKE.value, FilterOperator.NOT_ILIKE.value):
filter_: ValidatedQueryObjectFilterClause = {
"op": op,
"col": "category",
"val": "%book%",
}
with pytest.raises(ValueError, match="case-insensitive"):
_convert_query_object_filter(filter_, all_dimensions)
def test_convert_query_object_filter_is_null(mock_datasource: MagicMock) -> None:
"""
Test conversion of IS_NULL filter.