mirror of
https://github.com/apache/superset.git
synced 2026-06-11 02:29:19 +00:00
Compare commits
1 Commits
fix/chart-
...
semantic-v
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe0def2b09 |
@@ -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):
|
||||
|
||||
2
superset-frontend/package-lock.json
generated
2
superset-frontend/package-lock.json
generated
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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())}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 || '',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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' },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -306,6 +306,7 @@ export default function dashboardInfoReducer(
|
||||
...customization,
|
||||
targets: customization.targets?.map(target => ({
|
||||
datasetId: target.datasetId,
|
||||
datasourceType: target.datasourceType,
|
||||
})),
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -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> - {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');
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user