Compare commits

...

1 Commits

Author SHA1 Message Date
Beto Dealmeida
fe0def2b09 feat(semantic layers): dashboard filters
Fix label

Fix lint and tests

Address comments
2026-06-01 09:46:05 -04:00
24 changed files with 644 additions and 112 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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' },
]);
});

View File

@@ -306,6 +306,7 @@ export default function dashboardInfoReducer(
...customization,
targets: customization.targets?.map(target => ({
datasetId: target.datasetId,
datasourceType: target.datasourceType,
})),
}),
),

View File

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

View File

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

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

View File

@@ -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
@@ -566,7 +566,10 @@ 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, so ILIKE collapses to LIKE here; case-insensitivity is
# delegated to the semantic-view backend (e.g. via collation), matching the
# spec where pattern matching semantics are implementation-defined.
operator_mapping = {
FilterOperator.EQUALS.value: Operator.EQUALS,
FilterOperator.NOT_EQUALS.value: Operator.NOT_EQUALS,
@@ -576,6 +579,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,
@@ -598,11 +603,11 @@ def _convert_query_object_filter(
def _coerce_filter_value(
value: FilterValues | frozenset[FilterValues],
value: FilterValues | tuple[FilterValues, ...],
dimension: Dimension,
) -> FilterValues | frozenset[FilterValues]:
if isinstance(value, frozenset):
return frozenset(_coerce_scalar_filter_value(v, dimension) for v in value)
) -> FilterValues | tuple[FilterValues, ...]:
if isinstance(value, tuple):
return tuple(_coerce_scalar_filter_value(v, dimension) for v in value)
return _coerce_scalar_filter_value(value, dimension)

View File

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

View File

@@ -363,12 +363,39 @@ def test_convert_query_object_filter_in(mock_datasource: MagicMock) -> None:
result = _convert_query_object_filter(filter_, all_dimensions)
# IN values are converted to a tuple (not a set) so input order is preserved
# downstream — semantic-view backends may rely on it for stable plans.
assert result == {
Filter(
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%",
)
}
@@ -1262,7 +1289,7 @@ def test_convert_query_object_filter_coerces_in_integer_values() -> None:
type=PredicateType.WHERE,
column=all_dimensions["order_id__amount"],
operator=Operator.IN,
value=frozenset({58, 61}),
value=(58, 61),
)
}
@@ -2983,8 +3010,9 @@ def test_coerce_integer_rejects_non_integer_float() -> None:
def test_coerce_integer_rejects_other_types() -> None:
bad_value: Any = [1]
with pytest.raises(ValueError, match="Invalid integer value"):
_coerce_scalar_filter_value([1], _dim(pa.int64()))
_coerce_scalar_filter_value(bad_value, _dim(pa.int64()))
@pytest.mark.parametrize(
@@ -3008,8 +3036,9 @@ def test_coerce_floating_invalid_string_raises() -> None:
def test_coerce_floating_rejects_other_types() -> None:
bad_value: Any = [1.0]
with pytest.raises(ValueError, match="Invalid numeric value"):
_coerce_scalar_filter_value([1.0], _dim(pa.float64()))
_coerce_scalar_filter_value(bad_value, _dim(pa.float64()))
def test_coerce_date_from_datetime() -> None: