Compare commits

...

3 Commits

Author SHA1 Message Date
Beto Dealmeida
28e1cf520c Fix IN 2026-06-29 15:46:25 -04:00
Beto Dealmeida
4492d10081 Address comments 2026-06-29 15:37:19 -04:00
Beto Dealmeida
5bbca692ca feat(semantic layers): dashboard filters
Fix label

Fix lint and tests

Address comments
2026-06-29 15:24:01 -04:00
30 changed files with 909 additions and 212 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

@@ -304,14 +304,6 @@
"regenerator-runtime": "^0.14.1"
}
},
"eslint-rules/eslint-i18n-strings": {
"version": "1.0.0",
"extraneous": true,
"license": "Apache-2.0",
"peerDependencies": {
"eslint": ">=0.8.0"
}
},
"eslint-rules/eslint-plugin-i18n-strings": {
"version": "1.0.0",
"dev": true,
@@ -44733,59 +44725,6 @@
"node": ">=12"
}
},
"plugins/legacy-preset-chart-deckgl": {
"name": "@superset-ui/legacy-preset-chart-deckgl",
"version": "0.20.4",
"extraneous": true,
"license": "Apache-2.0",
"dependencies": {
"@deck.gl/aggregation-layers": "~9.2.11",
"@deck.gl/core": "~9.2.5",
"@deck.gl/extensions": "~9.2.9",
"@deck.gl/geo-layers": "~9.2.5",
"@deck.gl/layers": "~9.2.5",
"@deck.gl/mesh-layers": "~9.2.5",
"@deck.gl/react": "~9.2.11",
"@luma.gl/constants": "~9.2.5",
"@luma.gl/core": "~9.2.5",
"@luma.gl/engine": "~9.2.6",
"@luma.gl/shadertools": "~9.2.6",
"@luma.gl/webgl": "~9.2.6",
"@mapbox/geojson-extent": "^1.0.1",
"@mapbox/tiny-sdf": "^2.0.7",
"@math.gl/web-mercator": "^4.1.0",
"@types/d3-array": "^3.2.2",
"@types/geojson": "^7946.0.16",
"bootstrap-slider": "^11.0.2",
"d3-array": "^3.2.4",
"d3-color": "^3.1.0",
"d3-scale": "^4.0.2",
"handlebars": "^4.7.9",
"lodash": "^4.18.1",
"mousetrap": "^1.6.5",
"ngeohash": "^0.6.3",
"prop-types": "^15.8.1",
"underscore": "^1.13.8",
"urijs": "^1.19.11",
"xss": "^1.0.15"
},
"devDependencies": {
"@types/mapbox__geojson-extent": "^1.0.3",
"@types/ngeohash": "^0.6.8",
"@types/underscore": "^1.13.0",
"@types/urijs": "^1.19.26"
},
"peerDependencies": {
"@apache-superset/core": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"dayjs": "^1.11.19",
"mapbox-gl": "*",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-map-gl": "^6.1.19"
}
},
"plugins/legacy-preset-chart-nvd3": {
"name": "@superset-ui/legacy-preset-chart-nvd3",
"version": "0.20.3",
@@ -45162,19 +45101,6 @@
"engines": {
"node": ">=12"
}
},
"tools/eslint-i18n-strings": {
"version": "1.0.0",
"extraneous": true,
"license": "Apache-2.0",
"peerDependencies": {
"eslint": ">=0.8.0"
}
},
"tools/eslint-plugin-theme-colors": {
"version": "1.0.0",
"extraneous": true,
"license": "Apache-2.0"
}
}
}

View File

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

View File

@@ -179,8 +179,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);
@@ -214,6 +219,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,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 || '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,
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 +162,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

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

View File

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

View File

@@ -31,6 +31,7 @@ import {
NativeFiltersFormItem,
NativeFilterDivider,
} from '../types';
import { buildNativeFilterTarget } from './buildTarget';
type CustomizationFormInput =
| ChartCustomizationsFormItem
@@ -86,17 +87,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(

View File

@@ -32,6 +32,7 @@ import {
NativeFilterDivider,
ChartCustomizationsFormItem,
} from '../types';
import { buildNativeFilterTarget } from './buildTarget';
type FilterFormInput =
| NativeFiltersFormItem
@@ -88,20 +89,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(

View File

@@ -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;
@@ -91,6 +93,7 @@ export interface ChartCustomizationsFormItem {
granularity_sqla?: string;
type: typeof NativeFilterType.NativeFilter;
description: string;
datasourceType?: DatasourceType;
datasetInfo?: {
label: string | ReactNode;
value: number;

View File

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

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

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

View File

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

View File

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

View File

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

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

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

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

@@ -373,6 +373,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.
@@ -2983,8 +3005,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 +3031,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: