feat(native-filters): add configurable LIKE/ILIKE operators to Select filter (#38470)

Co-authored-by: Evan Rusackas <evan@preset.io>
Co-authored-by: Richard Fogaca Nienkotter <63572350+richardfogaca@users.noreply.github.com>
This commit is contained in:
endimonan
2026-03-17 01:11:53 +01:00
committed by GitHub
parent aa5adb0fce
commit 7909095ff3
11 changed files with 907 additions and 50 deletions

View File

@@ -30,6 +30,7 @@ const BINARY_OPERATORS = [
'<=',
'ILIKE',
'LIKE',
'NOT ILIKE',
'NOT LIKE',
'REGEX',
'TEMPORAL_RANGE',

View File

@@ -45,12 +45,16 @@ import {
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
RefObject,
memo,
} from 'react';
import rison from 'rison';
import { PluginFilterSelectCustomizeProps } from 'src/filters/components/Select/types';
import {
PluginFilterSelectCustomizeProps,
SelectFilterOperatorType,
} from 'src/filters/components/Select/types';
import { useSelector } from 'react-redux';
import { getChartDataRequest } from 'src/components/Chart/chartAction';
import {
@@ -624,6 +628,49 @@ const FiltersConfigForm = (
forceUpdate();
};
const currentOperatorType: SelectFilterOperatorType =
formFilter?.controlValues?.operatorType ??
filterToEdit?.controlValues?.operatorType ??
SelectFilterOperatorType.Exact;
const selectedColumnIsString = useMemo(() => {
const columnName = formFilter?.column;
if (!columnName || !datasetDetails?.columns) return true;
const colMeta = datasetDetails.columns.find(
(c: { column_name: string }) => c.column_name === columnName,
);
if (!colMeta) return true;
return colMeta.type_generic === GenericDataType.String;
}, [formFilter?.column, datasetDetails?.columns]);
const onOperatorTypeChanged = (value: SelectFilterOperatorType) => {
const previous = form.getFieldValue('filters')?.[filterId].controlValues;
setNativeFilterFieldValues(form, filterId, {
controlValues: {
...previous,
operatorType: value,
},
defaultDataMask: null,
});
formChanged();
forceUpdate();
};
const prevColumnRef = useRef(formFilter?.column);
const datasetLoaded = !!datasetDetails?.columns;
useEffect(() => {
const columnChanged = prevColumnRef.current !== formFilter?.column;
if (
(columnChanged || datasetLoaded) &&
!selectedColumnIsString &&
currentOperatorType !== SelectFilterOperatorType.Exact
) {
onOperatorTypeChanged(SelectFilterOperatorType.Exact);
}
prevColumnRef.current = formFilter?.column;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [formFilter?.column, selectedColumnIsString, datasetLoaded]);
const validatePreFilter = () =>
setTimeout(
() =>
@@ -685,6 +732,7 @@ const FiltersConfigForm = (
'columns.filterable',
'columns.is_dttm',
'columns.type',
'columns.type_generic',
'columns.verbose_name',
'database.id',
'database.database_name',
@@ -1501,6 +1549,67 @@ const FiltersConfigForm = (
hidden
initialValue={null}
/>
{!isChartCustomization &&
itemTypeField === 'filter_select' && (
<StyledRowFormItem
expanded={expanded}
name={[
'filters',
filterId,
'controlValues',
'operatorType',
]}
initialValue={currentOperatorType}
label={
<>
<StyledLabel>{t('Match type')}</StyledLabel>
&nbsp;
<InfoTooltip
placement="top"
tooltip={t(
'Warning: ILIKE queries may be slow on large datasets as they cannot use indexes effectively.',
)}
/>
</>
}
>
<Select
ariaLabel={t('Match type')}
options={[
{
value: SelectFilterOperatorType.Exact,
label: t('Exact match (IN)'),
},
...(selectedColumnIsString
? [
{
value:
SelectFilterOperatorType.Contains,
label: t(
'Contains text (ILIKE %x%)',
),
},
{
value:
SelectFilterOperatorType.StartsWith,
label: t('Starts with (ILIKE x%)'),
},
{
value:
SelectFilterOperatorType.EndsWith,
label: t('Ends with (ILIKE %x)'),
},
]
: []),
]}
onChange={value => {
onOperatorTypeChanged(
value as SelectFilterOperatorType,
);
}}
/>
</StyledRowFormItem>
)}
<FormItem
name={['filters', filterId, 'defaultValue']}
>

View File

@@ -165,7 +165,8 @@ export default function getControlItemsMap({
(controlItem: CustomControlItem) =>
controlItem?.config?.renderTrigger &&
controlItem.name !== 'sortAscending' &&
controlItem.name !== 'enableSingleValue',
controlItem.name !== 'enableSingleValue' &&
controlItem.name !== 'operatorType',
)
.forEach(controlItem => {
const initialValue =

View File

@@ -16,8 +16,11 @@
* specific language governing permissions and limitations
* under the License.
*/
import { AppSection } from '@superset-ui/core';
import { AppSection, Behavior, ChartProps } from '@superset-ui/core';
import { supersetTheme } from '@apache-superset/core/theme';
import {
act,
fireEvent,
render,
screen,
userEvent,
@@ -26,6 +29,13 @@ import {
import { NULL_STRING } from 'src/utils/common';
import SelectFilterPlugin from './SelectFilterPlugin';
import transformProps from './transformProps';
import { FilterState } from '@superset-ui/core';
import {
SelectFilterOperatorType,
PluginFilterSelectChartProps,
PluginFilterSelectProps,
PluginFilterSelectQueryFormData,
} from './types';
jest.useFakeTimers();
@@ -69,11 +79,36 @@ const selectMultipleProps = {
rejected_filters: [],
},
],
behaviors: ['NATIVE_FILTER'],
behaviors: [Behavior.NativeFilter],
isRefreshing: false,
appSection: AppSection.Dashboard,
};
type SelectTestOverrides = {
formData?: Partial<PluginFilterSelectQueryFormData>;
filterState?: Partial<FilterState>;
setDataMask?: jest.Mock;
};
const buildSelectFilterProps = (overrides: SelectTestOverrides = {}) => {
const chartProps = new ChartProps({
...selectMultipleProps,
formData: { ...selectMultipleProps.formData, ...overrides.formData },
...(overrides.filterState !== undefined && {
filterState: overrides.filterState,
}),
theme: supersetTheme,
}) as PluginFilterSelectChartProps;
return {
...transformProps(chartProps),
appSection: AppSection.Dashboard,
isRefreshing: false,
setDataMask: overrides.setDataMask ?? jest.fn(),
showOverflow: false,
} as PluginFilterSelectProps;
};
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('SelectFilterPlugin', () => {
const setDataMask = jest.fn();
@@ -1249,3 +1284,377 @@ test('resets dependent filter to first item when value does not exist in data',
);
});
});
test('renders text input instead of dropdown when operatorType is ILIKE contains', () => {
jest.useFakeTimers();
const setDataMaskMock = jest.fn();
const props = buildSelectFilterProps({
formData: { operatorType: SelectFilterOperatorType.Contains },
filterState: { value: undefined },
setDataMask: setDataMaskMock,
});
render(<SelectFilterPlugin {...props} />, {
useRedux: true,
initialState: {
nativeFilters: {
filters: { 'test-filter': { name: 'Test Filter' } },
},
dataMask: {
'test-filter': {
extraFormData: {},
filterState: { value: undefined },
},
},
},
});
expect(screen.queryByRole('combobox')).not.toBeInTheDocument();
expect(
screen.getByPlaceholderText('Type to search (contains)...'),
).toBeInTheDocument();
});
test('renders text input with starts-with placeholder', () => {
jest.useFakeTimers();
const setDataMaskMock = jest.fn();
const props = buildSelectFilterProps({
formData: { operatorType: SelectFilterOperatorType.StartsWith },
filterState: { value: undefined },
setDataMask: setDataMaskMock,
});
render(<SelectFilterPlugin {...props} />, {
useRedux: true,
initialState: {
nativeFilters: {
filters: { 'test-filter': { name: 'Test Filter' } },
},
dataMask: {
'test-filter': {
extraFormData: {},
filterState: { value: undefined },
},
},
},
});
expect(
screen.getByPlaceholderText('Type to search (starts with)...'),
).toBeInTheDocument();
});
test('typing in LIKE input calls setDataMask with ILIKE Contains payload', async () => {
jest.useFakeTimers();
const setDataMaskMock = jest.fn();
const props = buildSelectFilterProps({
formData: { operatorType: SelectFilterOperatorType.Contains },
filterState: { value: undefined },
setDataMask: setDataMaskMock,
});
render(<SelectFilterPlugin {...props} />, {
useRedux: true,
initialState: {
nativeFilters: {
filters: { 'test-filter': { name: 'Test Filter' } },
},
dataMask: {
'test-filter': {
extraFormData: {},
filterState: { value: undefined },
},
},
},
});
setDataMaskMock.mockClear();
const input = screen.getByPlaceholderText('Type to search (contains)...');
fireEvent.change(input, { target: { value: 'Jen' } });
act(() => {
jest.advanceTimersByTime(500);
});
expect(setDataMaskMock).toHaveBeenCalledWith(
expect.objectContaining({
extraFormData: {
filters: [
{
col: 'gender',
op: 'ILIKE',
val: '%Jen%',
},
],
},
}),
);
});
test('typing in LIKE input with inverse selection calls setDataMask with NOT ILIKE payload', async () => {
jest.useFakeTimers();
const setDataMaskMock = jest.fn();
const props = buildSelectFilterProps({
formData: {
operatorType: SelectFilterOperatorType.Contains,
inverseSelection: true,
},
filterState: { value: undefined },
setDataMask: setDataMaskMock,
});
render(<SelectFilterPlugin {...props} />, {
useRedux: true,
initialState: {
nativeFilters: {
filters: { 'test-filter': { name: 'Test Filter' } },
},
dataMask: {
'test-filter': {
extraFormData: {},
filterState: { value: undefined },
},
},
},
});
setDataMaskMock.mockClear();
const input = screen.getByPlaceholderText('Type to search (contains)...');
fireEvent.change(input, { target: { value: 'Jen' } });
act(() => {
jest.advanceTimersByTime(500);
});
expect(setDataMaskMock).toHaveBeenCalledWith(
expect.objectContaining({
extraFormData: {
filters: [
{
col: 'gender',
op: 'NOT ILIKE',
val: '%Jen%',
},
],
},
}),
);
});
test('clear-all resets LIKE input value and calls setDataMask with empty state', async () => {
jest.useFakeTimers();
const setDataMaskMock = jest.fn();
const likeProps = buildSelectFilterProps({
formData: { operatorType: SelectFilterOperatorType.Contains },
filterState: { value: ['Jen'] },
setDataMask: setDataMaskMock,
});
const reduxState = {
useRedux: true,
initialState: {
nativeFilters: {
filters: { 'test-filter': { name: 'Test Filter' } },
},
dataMask: {
'test-filter': {
extraFormData: {
filters: [{ col: 'gender', op: 'ILIKE', val: '%Jen%' }],
},
filterState: { value: ['Jen'] },
},
},
},
};
const { rerender } = render(
<SelectFilterPlugin {...likeProps} />,
reduxState,
);
const input = screen.getByPlaceholderText('Type to search (contains)...');
expect(input).toHaveValue('Jen');
setDataMaskMock.mockClear();
rerender(
<SelectFilterPlugin
{...likeProps}
clearAllTrigger={{ 'test-filter': true }}
/>,
);
await waitFor(() => {
expect(input).toHaveValue('');
});
await waitFor(() => {
expect(setDataMaskMock).toHaveBeenCalledWith(
expect.objectContaining({
filterState: expect.objectContaining({
value: null,
}),
}),
);
});
const callsBeforeDebounceFlush = setDataMaskMock.mock.calls.length;
act(() => {
jest.advanceTimersByTime(500);
});
expect(setDataMaskMock).toHaveBeenCalledTimes(callsBeforeDebounceFlush);
});
test('pending LIKE debounce still applies after rerender recreates updateDataMask', async () => {
jest.useFakeTimers();
const setDataMaskMock = jest.fn();
const likeProps = buildSelectFilterProps({
formData: { operatorType: SelectFilterOperatorType.Contains },
filterState: { value: undefined },
setDataMask: setDataMaskMock,
});
const reduxState = {
useRedux: true,
initialState: {
nativeFilters: {
filters: { 'test-filter': { name: 'Test Filter' } },
},
dataMask: {
'test-filter': {
extraFormData: {},
filterState: { value: undefined },
},
},
},
};
const { rerender } = render(
<SelectFilterPlugin {...likeProps} />,
reduxState,
);
fireEvent.change(
screen.getByPlaceholderText('Type to search (contains)...'),
{
target: { value: 'Jen' },
},
);
setDataMaskMock.mockClear();
rerender(
<SelectFilterPlugin
{...buildSelectFilterProps({
formData: { operatorType: SelectFilterOperatorType.Contains },
filterState: { value: undefined, label: 'external change' },
setDataMask: setDataMaskMock,
})}
/>,
);
act(() => {
jest.advanceTimersByTime(500);
});
expect(setDataMaskMock).toHaveBeenCalledWith(
expect.objectContaining({
extraFormData: {
filters: [
{
col: 'gender',
op: 'ILIKE',
val: '%Jen%',
},
],
},
}),
);
});
test('pending LIKE debounce is canceled when operatorType switches back to Exact', async () => {
jest.useFakeTimers();
const setDataMaskMock = jest.fn();
const likeProps = buildSelectFilterProps({
formData: { operatorType: SelectFilterOperatorType.Contains },
filterState: { value: undefined },
setDataMask: setDataMaskMock,
});
const reduxState = {
useRedux: true,
initialState: {
nativeFilters: {
filters: { 'test-filter': { name: 'Test Filter' } },
},
dataMask: {
'test-filter': {
extraFormData: {},
filterState: { value: undefined },
},
},
},
};
const { rerender } = render(
<SelectFilterPlugin {...likeProps} />,
reduxState,
);
fireEvent.change(
screen.getByPlaceholderText('Type to search (contains)...'),
{
target: { value: 'Jen' },
},
);
setDataMaskMock.mockClear();
rerender(
<SelectFilterPlugin
{...buildSelectFilterProps({
formData: { operatorType: SelectFilterOperatorType.Exact },
filterState: { value: undefined },
setDataMask: setDataMaskMock,
})}
/>,
);
act(() => {
jest.advanceTimersByTime(500);
});
expect(setDataMaskMock).not.toHaveBeenCalled();
});
test('renders standard Select dropdown when operatorType is Exact', () => {
jest.useFakeTimers();
const setDataMaskMock = jest.fn();
const props = buildSelectFilterProps({
formData: { operatorType: SelectFilterOperatorType.Exact },
setDataMask: setDataMaskMock,
});
render(<SelectFilterPlugin {...props} />, {
useRedux: true,
initialState: {
nativeFilters: {
filters: { 'test-filter': { name: 'Test Filter' } },
},
dataMask: {
'test-filter': {
extraFormData: {
filters: [{ col: 'gender', op: 'IN', val: ['boy'] }],
},
filterState: {
value: ['boy'],
label: 'boy',
excludeFilterValues: true,
},
},
},
},
});
expect(screen.getAllByRole('combobox').length).toBeGreaterThan(0);
});

View File

@@ -39,6 +39,7 @@ import {
Select,
Space,
Constants,
Input,
} from '@superset-ui/core/components';
import {
hasOption,
@@ -47,7 +48,11 @@ import {
import { FilterBarOrientation } from 'src/dashboard/types';
import { getDataRecordFormatter, getSelectExtraFormData } from '../../utils';
import { FilterPluginStyle, StatusMessage } from '../common';
import { PluginFilterSelectProps, SelectValue } from './types';
import {
PluginFilterSelectProps,
SelectFilterOperatorType,
SelectValue,
} from './types';
type DataMaskAction =
| { type: 'ownState'; ownState: JsonObject }
@@ -142,6 +147,7 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
inverseSelection,
defaultToFirstItem,
searchAllOptions,
operatorType = SelectFilterOperatorType.Exact,
} = formData;
const groupby = useMemo(
@@ -157,6 +163,9 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
filterState,
});
const datatype: GenericDataType = coltypeMap[col];
const isLikeOperator =
operatorType !== SelectFilterOperatorType.Exact &&
datatype === GenericDataType.String;
const labelFormatter = useMemo(
() =>
getDataRecordFormatter({
@@ -170,6 +179,16 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
: filterState?.excludeFilterValues,
);
const [likeInputValue, setLikeInputValue] = useState<string>(
filterState.value?.[0] != null ? String(filterState.value[0]) : '',
);
useEffect(() => {
const externalValue =
filterState.value?.[0] != null ? String(filterState.value[0]) : '';
setLikeInputValue(externalValue);
}, [filterState.value]);
const prevExcludeFilterValues = useRef(excludeFilterValues);
const hasOnlyOrientationChanged = useRef(false);
@@ -207,6 +226,7 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
values,
emptyFilter,
excludeFilterValues && inverseSelection,
operatorType,
),
filterState: {
...filterState,
@@ -233,6 +253,7 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
enableEmptyFilter,
inverseSelection,
excludeFilterValues,
operatorType,
JSON.stringify(filterState),
labelFormatter,
],
@@ -452,6 +473,7 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
updateDataMask(null);
setSearch('');
setLikeInputValue('');
onClearAllComplete?.(formData.nativeFilterId);
}
}, [clearAllTrigger, onClearAllComplete, updateDataMask]);
@@ -465,6 +487,7 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
filterState.value,
!filterState.value?.length,
excludeFilterValues && inverseSelection,
operatorType,
),
filterState: {
...(filterState as {
@@ -483,6 +506,52 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
setExcludeFilterValues(value === 'true');
};
const updateDataMaskRef = useRef(updateDataMask);
useEffect(() => {
updateDataMaskRef.current = updateDataMask;
}, [updateDataMask]);
const debouncedLikeChange = useMemo(
() =>
debounce((text: string) => {
if (text) {
updateDataMaskRef.current([text]);
} else {
updateDataMaskRef.current(null);
}
}, Constants.SLOW_DEBOUNCE),
[],
);
useEffect(() => {
if (!isLikeOperator || clearAllTrigger) {
debouncedLikeChange.cancel();
}
}, [clearAllTrigger, debouncedLikeChange, isLikeOperator]);
useEffect(() => () => debouncedLikeChange.cancel(), [debouncedLikeChange]);
const handleLikeInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setLikeInputValue(e.target.value);
debouncedLikeChange(e.target.value);
},
[debouncedLikeChange],
);
const likeInputPlaceholder = useMemo(() => {
switch (operatorType) {
case SelectFilterOperatorType.Contains:
return t('Type to search (contains)...');
case SelectFilterOperatorType.StartsWith:
return t('Type to search (starts with)...');
case SelectFilterOperatorType.EndsWith:
return t('Type to search (ends with)...');
default:
return t('Type a value...');
}
}, [operatorType]);
return (
<FilterPluginStyle height={height} width={width}>
<FormItem
@@ -504,39 +573,54 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
onChange={handleExclusionToggle}
/>
)}
<Select
name={formData.nativeFilterId}
allowClear
allowNewOptions={!searchAllOptions && creatable !== false}
allowSelectAll={!searchAllOptions}
value={multiSelect ? filterState.value || [] : filterState.value}
disabled={isDisabled}
getPopupContainer={
showOverflow
? () => (parentRef?.current as HTMLElement) || document.body
: (trigger: HTMLElement) =>
(trigger?.parentNode as HTMLElement) || document.body
}
showSearch={showSearch}
mode={multiSelect ? 'multiple' : 'single'}
placeholder={placeholderText}
onClear={() => onSearch('')}
onSearch={onSearch}
onBlur={handleBlur}
onFocus={setFocusedFilter}
onMouseEnter={setHoveredFilter}
onMouseLeave={unsetHoveredFilter}
// @ts-expect-error
onChange={handleChange}
ref={inputRef}
loading={isRefreshing}
oneLine={filterBarOrientation === FilterBarOrientation.Horizontal}
invertSelection={inverseSelection && excludeFilterValues}
options={options}
sortComparator={sortComparator}
onOpenChange={setFilterActive}
className="select-container"
/>
{isLikeOperator ? (
<Input
allowClear
placeholder={likeInputPlaceholder}
value={likeInputValue}
onChange={handleLikeInputChange}
onFocus={setFocusedFilter}
onBlur={unsetFocusedFilter}
onMouseEnter={setHoveredFilter}
onMouseLeave={unsetHoveredFilter}
disabled={isDisabled}
ref={inputRef}
/>
) : (
<Select
name={formData.nativeFilterId}
allowClear
allowNewOptions={!searchAllOptions && creatable !== false}
allowSelectAll={!searchAllOptions}
value={multiSelect ? filterState.value || [] : filterState.value}
disabled={isDisabled}
getPopupContainer={
showOverflow
? () => (parentRef?.current as HTMLElement) || document.body
: (trigger: HTMLElement) =>
(trigger?.parentNode as HTMLElement) || document.body
}
showSearch={showSearch}
mode={multiSelect ? 'multiple' : 'single'}
placeholder={placeholderText}
onClear={() => onSearch('')}
onSearch={onSearch}
onBlur={handleBlur}
onFocus={setFocusedFilter}
onMouseEnter={setHoveredFilter}
onMouseLeave={unsetHoveredFilter}
// @ts-expect-error
onChange={handleChange}
ref={inputRef}
loading={isRefreshing}
oneLine={filterBarOrientation === FilterBarOrientation.Horizontal}
invertSelection={inverseSelection && excludeFilterValues}
options={options}
sortComparator={sortComparator}
onOpenChange={setFilterActive}
className="select-container"
/>
)}
</StyledSpace>
</FormItem>
</FilterPluginStyle>

View File

@@ -18,7 +18,11 @@
*/
import { GenericDataType } from '@apache-superset/core/common';
import buildQuery from './buildQuery';
import { PluginFilterSelectQueryFormData } from './types';
import {
PluginFilterSelectQueryFormData,
SelectFilterOperatorType,
} from './types';
import { getSelectExtraFormData } from '../../utils';
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('Select buildQuery', () => {
@@ -127,3 +131,91 @@ describe('Select buildQuery', () => {
]);
});
});
test('getSelectExtraFormData generates IN filter by default', () => {
const result = getSelectExtraFormData('name', ['Jennifer']);
expect(result.filters).toEqual([
{ col: 'name', op: 'IN', val: ['Jennifer'] },
]);
});
test('getSelectExtraFormData generates NOT IN filter with excludeFilter', () => {
const result = getSelectExtraFormData('name', ['Jennifer'], false, true);
expect(result.filters).toEqual([
{ col: 'name', op: 'NOT IN', val: ['Jennifer'] },
]);
});
test('getSelectExtraFormData generates ILIKE contains filter', () => {
const result = getSelectExtraFormData(
'name',
['Jen'],
false,
false,
SelectFilterOperatorType.Contains,
);
expect(result.filters).toEqual([{ col: 'name', op: 'ILIKE', val: '%Jen%' }]);
});
test('getSelectExtraFormData generates ILIKE starts-with filter', () => {
const result = getSelectExtraFormData(
'name',
['Jen'],
false,
false,
SelectFilterOperatorType.StartsWith,
);
expect(result.filters).toEqual([{ col: 'name', op: 'ILIKE', val: 'Jen%' }]);
});
test('getSelectExtraFormData generates ILIKE ends-with filter', () => {
const result = getSelectExtraFormData(
'name',
['son'],
false,
false,
SelectFilterOperatorType.EndsWith,
);
expect(result.filters).toEqual([{ col: 'name', op: 'ILIKE', val: '%son' }]);
});
test('getSelectExtraFormData generates NOT ILIKE with excludeFilter and LIKE operator', () => {
const result = getSelectExtraFormData(
'name',
['Jen'],
false,
true,
SelectFilterOperatorType.Contains,
);
expect(result.filters).toEqual([
{ col: 'name', op: 'NOT ILIKE', val: '%Jen%' },
]);
});
test('getSelectExtraFormData returns empty object for null value with LIKE operator', () => {
const result = getSelectExtraFormData(
'name',
null,
false,
false,
SelectFilterOperatorType.Contains,
);
expect(result).toEqual({});
});
test('getSelectExtraFormData returns adhoc_filters for emptyFilter with LIKE operator', () => {
const result = getSelectExtraFormData(
'name',
[],
true,
false,
SelectFilterOperatorType.Contains,
);
expect(result.adhoc_filters).toEqual([
{
expressionType: 'SQL',
clause: 'WHERE',
sqlExpression: '1 = 0',
},
]);
});

View File

@@ -0,0 +1,43 @@
/**
* 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 { GenericDataType } from '@apache-superset/core/common';
import { getOperatorTypeChoices, isStringOperatorColumn } from './controlPanel';
import { SelectFilterOperatorType } from './types';
test('getOperatorTypeChoices only returns exact match for non-string columns', () => {
expect(getOperatorTypeChoices(false)).toEqual([
[SelectFilterOperatorType.Exact, 'Exact match (IN)'],
]);
});
test('isStringOperatorColumn returns false for numeric selected columns', () => {
expect(
isStringOperatorColumn('num_col', [
{ column_name: 'num_col', type_generic: GenericDataType.Numeric },
]),
).toBe(false);
});
test('isStringOperatorColumn returns true for string selected columns', () => {
expect(
isStringOperatorColumn('name', [
{ column_name: 'name', type_generic: GenericDataType.String },
]),
).toBe(true);
});

View File

@@ -17,12 +17,13 @@
* under the License.
*/
import { t } from '@apache-superset/core/translation';
import { validateNonEmpty } from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/common';
import { ensureIsArray, validateNonEmpty } from '@superset-ui/core';
import {
ControlPanelConfig,
sharedControls,
} from '@superset-ui/chart-controls';
import { DEFAULT_FORM_DATA } from './types';
import { DEFAULT_FORM_DATA, SelectFilterOperatorType } from './types';
const {
enableEmptyFilter,
@@ -32,8 +33,40 @@ const {
defaultToFirstItem,
searchAllOptions,
sortAscending,
operatorType,
} = DEFAULT_FORM_DATA;
type FilterSelectColumn = {
column_name: string;
type_generic?: GenericDataType | null;
};
export const getOperatorTypeChoices = (isStringColumn: boolean) => [
[SelectFilterOperatorType.Exact, t('Exact match (IN)')],
...(isStringColumn
? [
[SelectFilterOperatorType.Contains, t('Contains text (ILIKE %x%)')],
[SelectFilterOperatorType.StartsWith, t('Starts with (ILIKE x%)')],
[SelectFilterOperatorType.EndsWith, t('Ends with (ILIKE %x)')],
]
: []),
];
export const isStringOperatorColumn = (
selectedColumn: unknown,
columns?: FilterSelectColumn[],
) => {
const columnName = ensureIsArray(selectedColumn)[0];
if (!columnName || !columns) {
return true;
}
const column = columns.find(col => col.column_name === columnName);
if (!column || column.type_generic == null) {
return true;
}
return column.type_generic === GenericDataType.String;
};
const config: ControlPanelConfig = {
controlPanelSections: [
{
@@ -56,6 +89,44 @@ const config: ControlPanelConfig = {
label: t('UI Configuration'),
expanded: true,
controlSetRows: [
[
{
name: 'operatorType',
config: {
type: 'SelectControl',
renderTrigger: true,
affectsDataMask: true,
label: t('Match type'),
default: operatorType,
choices: getOperatorTypeChoices(true),
description: t(
'Determines how the filter matches values. ' +
'"Exact match" uses the IN operator (default). ' +
'ILIKE options enable partial text matching with a free-text input. ' +
'Warning: ILIKE queries may be slow on large datasets as they cannot use indexes effectively.',
),
mapStateToProps: state => {
const isStringColumn = isStringOperatorColumn(
state.controls?.groupby?.value,
state.datasource?.columns as FilterSelectColumn[] | undefined,
);
return {
choices: getOperatorTypeChoices(isStringColumn),
description: isStringColumn
? t(
'Determines how the filter matches values. ' +
'"Exact match" uses the IN operator (default). ' +
'ILIKE options enable partial text matching with a free-text input. ' +
'Warning: ILIKE queries may be slow on large datasets as they cannot use indexes effectively.',
)
: t(
'Only exact match is available for non-string columns.',
),
};
},
},
},
],
[
{
name: 'sortAscending',

View File

@@ -18,7 +18,11 @@
*/
import { GenericDataType } from '@apache-superset/core/common';
import { noOp } from 'src/utils/common';
import { DEFAULT_FORM_DATA, PluginFilterSelectChartProps } from './types';
import {
DEFAULT_FORM_DATA,
PluginFilterSelectChartProps,
PluginFilterSelectQueryFormData,
} from './types';
export default function transformProps(
chartProps: PluginFilterSelectChartProps,
@@ -36,7 +40,10 @@ export default function transformProps(
isRefreshing,
inputRef,
} = chartProps;
const newFormData = { ...DEFAULT_FORM_DATA, ...formData };
const newFormData = {
...DEFAULT_FORM_DATA,
...(formData as PluginFilterSelectQueryFormData),
};
const {
setDataMask = noOp,
setHoveredFilter = noOp,

View File

@@ -32,6 +32,13 @@ import { PluginFilterHooks, PluginFilterStylesProps } from '../types';
export type SelectValue = (number | string | null)[] | null | undefined;
export enum SelectFilterOperatorType {
Exact = 'exact',
Contains = 'ilike_contains',
StartsWith = 'ilike_starts_with',
EndsWith = 'ilike_ends_with',
}
export interface PluginFilterSelectCustomizeProps {
defaultValue?: SelectValue;
enableEmptyFilter: boolean;
@@ -42,6 +49,7 @@ export interface PluginFilterSelectCustomizeProps {
searchAllOptions: boolean;
sortAscending?: boolean;
sortMetric?: string;
operatorType?: SelectFilterOperatorType;
}
export type PluginFilterSelectQueryFormData = QueryFormData &
@@ -78,4 +86,5 @@ export const DEFAULT_FORM_DATA: PluginFilterSelectCustomizeProps = {
multiSelect: true,
searchAllOptions: false,
sortAscending: true,
operatorType: SelectFilterOperatorType.Exact,
};

View File

@@ -29,12 +29,30 @@ import {
Clauses,
ExpressionTypes,
} from '../explore/components/controls/FilterControl/types';
import { SelectFilterOperatorType } from './components/Select/types';
function applyWildcard(
value: string,
operatorType: SelectFilterOperatorType,
): string {
switch (operatorType) {
case SelectFilterOperatorType.Contains:
return `%${value}%`;
case SelectFilterOperatorType.StartsWith:
return `${value}%`;
case SelectFilterOperatorType.EndsWith:
return `%${value}`;
default:
return value;
}
}
export const getSelectExtraFormData = (
col: string,
value?: null | (string | number | boolean | null)[],
emptyFilter = false,
shouldExcludeFilter = false,
operatorType: SelectFilterOperatorType = SelectFilterOperatorType.Exact,
): ExtraFormData => {
const extra: ExtraFormData = {};
if (emptyFilter) {
@@ -46,13 +64,26 @@ export const getSelectExtraFormData = (
},
];
} else if (value !== undefined && value !== null && value.length !== 0) {
extra.filters = [
{
col,
op: shouldExcludeFilter ? ('NOT IN' as const) : ('IN' as const),
val: value,
},
];
const isLikeOperator = operatorType !== SelectFilterOperatorType.Exact;
if (isLikeOperator && typeof value[0] === 'string') {
const wildcardVal = applyWildcard(value[0] as string, operatorType);
extra.filters = [
{
col,
op: shouldExcludeFilter ? ('NOT ILIKE' as const) : ('ILIKE' as const),
val: wildcardVal,
},
];
} else {
extra.filters = [
{
col,
op: shouldExcludeFilter ? ('NOT IN' as const) : ('IN' as const),
val: value,
},
];
}
}
return extra;
};