mirror of
https://github.com/apache/superset.git
synced 2024-09-16 02:29:39 -04:00
feat(native-filters): add search all filter options (#14710)
* feat(native-filters): add search all filter options * add tests * fix default value * implement ILIKE operator * rebump packages * fix test * address comments * fix state changes coming from application * fix debouncer
This commit is contained in:
parent
8484ee653f
commit
e9657afe4b
604
superset-frontend/package-lock.json
generated
604
superset-frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -67,35 +67,35 @@
|
|||||||
"@emotion/babel-preset-css-prop": "^11.2.0",
|
"@emotion/babel-preset-css-prop": "^11.2.0",
|
||||||
"@emotion/cache": "^11.1.3",
|
"@emotion/cache": "^11.1.3",
|
||||||
"@emotion/react": "^11.1.5",
|
"@emotion/react": "^11.1.5",
|
||||||
"@superset-ui/chart-controls": "^0.17.48",
|
"@superset-ui/chart-controls": "^0.17.50",
|
||||||
"@superset-ui/core": "^0.17.48",
|
"@superset-ui/core": "^0.17.50",
|
||||||
"@superset-ui/legacy-plugin-chart-calendar": "0.17.48",
|
"@superset-ui/legacy-plugin-chart-calendar": "^0.17.50",
|
||||||
"@superset-ui/legacy-plugin-chart-chord": "0.17.48",
|
"@superset-ui/legacy-plugin-chart-chord": "^0.17.50",
|
||||||
"@superset-ui/legacy-plugin-chart-country-map": "0.17.48",
|
"@superset-ui/legacy-plugin-chart-country-map": "^0.17.50",
|
||||||
"@superset-ui/legacy-plugin-chart-event-flow": "0.17.48",
|
"@superset-ui/legacy-plugin-chart-event-flow": "^0.17.50",
|
||||||
"@superset-ui/legacy-plugin-chart-force-directed": "0.17.48",
|
"@superset-ui/legacy-plugin-chart-force-directed": "^0.17.50",
|
||||||
"@superset-ui/legacy-plugin-chart-heatmap": "0.17.48",
|
"@superset-ui/legacy-plugin-chart-heatmap": "^0.17.50",
|
||||||
"@superset-ui/legacy-plugin-chart-histogram": "0.17.48",
|
"@superset-ui/legacy-plugin-chart-histogram": "^0.17.50",
|
||||||
"@superset-ui/legacy-plugin-chart-horizon": "0.17.48",
|
"@superset-ui/legacy-plugin-chart-horizon": "^0.17.50",
|
||||||
"@superset-ui/legacy-plugin-chart-map-box": "0.17.48",
|
"@superset-ui/legacy-plugin-chart-map-box": "^0.17.50",
|
||||||
"@superset-ui/legacy-plugin-chart-paired-t-test": "0.17.48",
|
"@superset-ui/legacy-plugin-chart-paired-t-test": "^0.17.50",
|
||||||
"@superset-ui/legacy-plugin-chart-parallel-coordinates": "0.17.48",
|
"@superset-ui/legacy-plugin-chart-parallel-coordinates": "^0.17.50",
|
||||||
"@superset-ui/legacy-plugin-chart-partition": "0.17.48",
|
"@superset-ui/legacy-plugin-chart-partition": "^0.17.50",
|
||||||
"@superset-ui/legacy-plugin-chart-pivot-table": "0.17.48",
|
"@superset-ui/legacy-plugin-chart-pivot-table": "^0.17.50",
|
||||||
"@superset-ui/legacy-plugin-chart-rose": "0.17.48",
|
"@superset-ui/legacy-plugin-chart-rose": "^0.17.50",
|
||||||
"@superset-ui/legacy-plugin-chart-sankey": "^0.17.48",
|
"@superset-ui/legacy-plugin-chart-sankey": "^0.17.50",
|
||||||
"@superset-ui/legacy-plugin-chart-sankey-loop": "0.17.48",
|
"@superset-ui/legacy-plugin-chart-sankey-loop": "^0.17.50",
|
||||||
"@superset-ui/legacy-plugin-chart-sunburst": "0.17.48",
|
"@superset-ui/legacy-plugin-chart-sunburst": "^0.17.50",
|
||||||
"@superset-ui/legacy-plugin-chart-treemap": "0.17.48",
|
"@superset-ui/legacy-plugin-chart-treemap": "^0.17.50",
|
||||||
"@superset-ui/legacy-plugin-chart-world-map": "0.17.48",
|
"@superset-ui/legacy-plugin-chart-world-map": "^0.17.50",
|
||||||
"@superset-ui/legacy-preset-chart-big-number": "0.17.48",
|
"@superset-ui/legacy-preset-chart-big-number": "^0.17.50",
|
||||||
"@superset-ui/legacy-preset-chart-deckgl": "^0.4.6",
|
"@superset-ui/legacy-preset-chart-deckgl": "^0.4.6",
|
||||||
"@superset-ui/legacy-preset-chart-nvd3": "0.17.48",
|
"@superset-ui/legacy-preset-chart-nvd3": "^0.17.50",
|
||||||
"@superset-ui/plugin-chart-echarts": "^0.17.48",
|
"@superset-ui/plugin-chart-echarts": "^0.17.50",
|
||||||
"@superset-ui/plugin-chart-pivot-table": "^0.17.48",
|
"@superset-ui/plugin-chart-pivot-table": "^0.17.50",
|
||||||
"@superset-ui/plugin-chart-table": "^0.17.48",
|
"@superset-ui/plugin-chart-table": "^0.17.50",
|
||||||
"@superset-ui/plugin-chart-word-cloud": "0.17.48",
|
"@superset-ui/plugin-chart-word-cloud": "^0.17.50",
|
||||||
"@superset-ui/preset-chart-xy": "0.17.48",
|
"@superset-ui/preset-chart-xy": "^0.17.50",
|
||||||
"@vx/responsive": "^0.0.195",
|
"@vx/responsive": "^0.0.195",
|
||||||
"abortcontroller-polyfill": "^1.1.9",
|
"abortcontroller-polyfill": "^1.1.9",
|
||||||
"antd": "^4.9.4",
|
"antd": "^4.9.4",
|
||||||
|
@ -25,6 +25,8 @@ import {
|
|||||||
t,
|
t,
|
||||||
Behavior,
|
Behavior,
|
||||||
ChartDataResponseResult,
|
ChartDataResponseResult,
|
||||||
|
JsonObject,
|
||||||
|
getChartMetadataRegistry,
|
||||||
} from '@superset-ui/core';
|
} from '@superset-ui/core';
|
||||||
import { useDispatch } from 'react-redux';
|
import { useDispatch } from 'react-redux';
|
||||||
import { areObjectsEqual } from 'src/reduxUtils';
|
import { areObjectsEqual } from 'src/reduxUtils';
|
||||||
@ -53,10 +55,12 @@ const FilterValue: React.FC<FilterProps> = ({
|
|||||||
onFilterSelectionChange,
|
onFilterSelectionChange,
|
||||||
}) => {
|
}) => {
|
||||||
const { id, targets, filterType, adhoc_filters, time_range } = filter;
|
const { id, targets, filterType, adhoc_filters, time_range } = filter;
|
||||||
|
const metadata = getChartMetadataRegistry().get(filterType);
|
||||||
const cascadingFilters = useCascadingFilters(id);
|
const cascadingFilters = useCascadingFilters(id);
|
||||||
const [state, setState] = useState<ChartDataResponseResult[]>([]);
|
const [state, setState] = useState<ChartDataResponseResult[]>([]);
|
||||||
const [error, setError] = useState<string>('');
|
const [error, setError] = useState<string>('');
|
||||||
const [formData, setFormData] = useState<Partial<QueryFormData>>({});
|
const [formData, setFormData] = useState<Partial<QueryFormData>>({});
|
||||||
|
const [ownState, setOwnState] = useState<JsonObject>({});
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const [target] = targets;
|
const [target] = targets;
|
||||||
const {
|
const {
|
||||||
@ -65,7 +69,8 @@ const FilterValue: React.FC<FilterProps> = ({
|
|||||||
}: Partial<{ datasetId: number; column: { name?: string } }> = target;
|
}: Partial<{ datasetId: number; column: { name?: string } }> = target;
|
||||||
const { name: groupby } = column;
|
const { name: groupby } = column;
|
||||||
const hasDataSource = !!datasetId;
|
const hasDataSource = !!datasetId;
|
||||||
const [loading, setLoading] = useState<boolean>(hasDataSource);
|
const [isLoading, setIsLoading] = useState<boolean>(hasDataSource);
|
||||||
|
const [isRefreshing, setIsRefreshing] = useState<boolean>(false);
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const newFormData = getFormData({
|
const newFormData = getFormData({
|
||||||
@ -77,16 +82,22 @@ const FilterValue: React.FC<FilterProps> = ({
|
|||||||
adhoc_filters,
|
adhoc_filters,
|
||||||
time_range,
|
time_range,
|
||||||
});
|
});
|
||||||
if (!areObjectsEqual(formData, newFormData)) {
|
const filterOwnState = filter.dataMask?.ownState || {};
|
||||||
|
if (
|
||||||
|
!areObjectsEqual(formData, newFormData) ||
|
||||||
|
!areObjectsEqual(ownState, filterOwnState)
|
||||||
|
) {
|
||||||
setFormData(newFormData);
|
setFormData(newFormData);
|
||||||
|
setOwnState(filterOwnState);
|
||||||
if (!hasDataSource) {
|
if (!hasDataSource) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setIsRefreshing(true);
|
||||||
getChartDataRequest({
|
getChartDataRequest({
|
||||||
formData: newFormData,
|
formData: newFormData,
|
||||||
force: false,
|
force: false,
|
||||||
requestParams: { dashboardId: 0 },
|
requestParams: { dashboardId: 0 },
|
||||||
ownState: filter.dataMask?.ownState,
|
ownState: filterOwnState,
|
||||||
})
|
})
|
||||||
.then(response => {
|
.then(response => {
|
||||||
if (isFeatureEnabled(FeatureFlag.GLOBAL_ASYNC_QUERIES)) {
|
if (isFeatureEnabled(FeatureFlag.GLOBAL_ASYNC_QUERIES)) {
|
||||||
@ -94,24 +105,28 @@ const FilterValue: React.FC<FilterProps> = ({
|
|||||||
const result = 'result' in response ? response.result[0] : response;
|
const result = 'result' in response ? response.result[0] : response;
|
||||||
waitForAsyncData(result)
|
waitForAsyncData(result)
|
||||||
.then((asyncResult: ChartDataResponseResult[]) => {
|
.then((asyncResult: ChartDataResponseResult[]) => {
|
||||||
setLoading(false);
|
setIsRefreshing(false);
|
||||||
|
setIsLoading(false);
|
||||||
setState(asyncResult);
|
setState(asyncResult);
|
||||||
})
|
})
|
||||||
.catch((error: ClientErrorObject) => {
|
.catch((error: ClientErrorObject) => {
|
||||||
setError(
|
setError(
|
||||||
error.message || error.error || t('Check configuration'),
|
error.message || error.error || t('Check configuration'),
|
||||||
);
|
);
|
||||||
setLoading(false);
|
setIsRefreshing(false);
|
||||||
|
setIsLoading(false);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
setState(response.result);
|
setState(response.result);
|
||||||
setError('');
|
setError('');
|
||||||
setLoading(false);
|
setIsRefreshing(false);
|
||||||
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error: Response) => {
|
.catch((error: Response) => {
|
||||||
setError(error.statusText);
|
setError(error.statusText);
|
||||||
setLoading(false);
|
setIsRefreshing(false);
|
||||||
|
setIsLoading(false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
@ -151,7 +166,7 @@ const FilterValue: React.FC<FilterProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<FilterItem data-test="form-item-value">
|
<FilterItem data-test="form-item-value">
|
||||||
{loading ? (
|
{isLoading ? (
|
||||||
<Loading position="inline-centered" />
|
<Loading position="inline-centered" />
|
||||||
) : (
|
) : (
|
||||||
<SuperChart
|
<SuperChart
|
||||||
@ -164,6 +179,8 @@ const FilterValue: React.FC<FilterProps> = ({
|
|||||||
behaviors={[Behavior.NATIVE_FILTER]}
|
behaviors={[Behavior.NATIVE_FILTER]}
|
||||||
filterState={filter.dataMask?.filterState}
|
filterState={filter.dataMask?.filterState}
|
||||||
ownState={filter.dataMask?.ownState}
|
ownState={filter.dataMask?.ownState}
|
||||||
|
enableNoResults={metadata?.enableNoResults}
|
||||||
|
isRefreshing={isRefreshing}
|
||||||
hooks={{ setDataMask, setFocusedFilter, unsetFocusedFilter }}
|
hooks={{ setDataMask, setFocusedFilter, unsetFocusedFilter }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
@ -34,6 +34,7 @@ type DefaultValueProps = {
|
|||||||
hasDataset: boolean;
|
hasDataset: boolean;
|
||||||
form: FormInstance<NativeFiltersForm>;
|
form: FormInstance<NativeFiltersForm>;
|
||||||
formData: ReturnType<typeof getFormData>;
|
formData: ReturnType<typeof getFormData>;
|
||||||
|
enableNoResults: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DefaultValue: FC<DefaultValueProps> = ({
|
const DefaultValue: FC<DefaultValueProps> = ({
|
||||||
@ -42,6 +43,7 @@ const DefaultValue: FC<DefaultValueProps> = ({
|
|||||||
form,
|
form,
|
||||||
setDataMask,
|
setDataMask,
|
||||||
formData,
|
formData,
|
||||||
|
enableNoResults,
|
||||||
}) => {
|
}) => {
|
||||||
const [loading, setLoading] = useState(hasDataset);
|
const [loading, setLoading] = useState(hasDataset);
|
||||||
const formFilter = (form.getFieldValue('filters') || {})[filterId];
|
const formFilter = (form.getFieldValue('filters') || {})[filterId];
|
||||||
@ -70,6 +72,7 @@ const DefaultValue: FC<DefaultValueProps> = ({
|
|||||||
}
|
}
|
||||||
chartType={formFilter?.filterType}
|
chartType={formFilter?.filterType}
|
||||||
hooks={{ setDataMask }}
|
hooks={{ setDataMask }}
|
||||||
|
enableNoResults={enableNoResults}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -204,7 +204,9 @@ export const FiltersConfigForm: React.FC<FiltersConfigFormProps> = ({
|
|||||||
?.datasourceCount;
|
?.datasourceCount;
|
||||||
const hasColumn =
|
const hasColumn =
|
||||||
hasDataset && !FILTERS_WITHOUT_COLUMN.includes(formFilter?.filterType);
|
hasDataset && !FILTERS_WITHOUT_COLUMN.includes(formFilter?.filterType);
|
||||||
|
// @ts-ignore
|
||||||
|
const enableNoResults = !!nativeFilterItems[formFilter?.filterType]?.value
|
||||||
|
?.enableNoResults;
|
||||||
const datasetId = formFilter?.dataset?.value;
|
const datasetId = formFilter?.dataset?.value;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -484,6 +486,7 @@ export const FiltersConfigForm: React.FC<FiltersConfigFormProps> = ({
|
|||||||
hasDataset={hasDataset}
|
hasDataset={hasDataset}
|
||||||
form={form}
|
form={form}
|
||||||
formData={newFormData}
|
formData={newFormData}
|
||||||
|
enableNoResults={enableNoResults}
|
||||||
/>
|
/>
|
||||||
) : hasFilledDataset ? (
|
) : hasFilledDataset ? (
|
||||||
t('Click "Populate" to get "Default Value" ->')
|
t('Click "Populate" to get "Default Value" ->')
|
||||||
|
@ -72,7 +72,7 @@ export const getFormData = ({
|
|||||||
extra_form_data: cascadingFilters,
|
extra_form_data: cascadingFilters,
|
||||||
granularity_sqla: 'ds',
|
granularity_sqla: 'ds',
|
||||||
metrics: ['count'],
|
metrics: ['count'],
|
||||||
row_limit: 10000,
|
row_limit: 1000,
|
||||||
showSearch: true,
|
showSearch: true,
|
||||||
defaultValue: defaultDataMask?.filterState?.value,
|
defaultValue: defaultDataMask?.filterState?.value,
|
||||||
time_range,
|
time_range,
|
||||||
|
@ -39,6 +39,7 @@ const OPERATORS_TO_SQL = {
|
|||||||
IN: 'IN',
|
IN: 'IN',
|
||||||
'NOT IN': 'NOT IN',
|
'NOT IN': 'NOT IN',
|
||||||
LIKE: 'LIKE',
|
LIKE: 'LIKE',
|
||||||
|
ILIKE: 'ILIKE',
|
||||||
REGEX: 'REGEX',
|
REGEX: 'REGEX',
|
||||||
'IS NOT NULL': 'IS NOT NULL',
|
'IS NOT NULL': 'IS NOT NULL',
|
||||||
'IS NULL': 'IS NULL',
|
'IS NULL': 'IS NULL',
|
||||||
|
@ -84,6 +84,9 @@ function translateOperator(operator) {
|
|||||||
if (operator === OPERATORS.LIKE) {
|
if (operator === OPERATORS.LIKE) {
|
||||||
return 'LIKE';
|
return 'LIKE';
|
||||||
}
|
}
|
||||||
|
if (operator === OPERATORS.ILIKE) {
|
||||||
|
return 'LIKE (case insensitive)';
|
||||||
|
}
|
||||||
if (operator === OPERATORS['LATEST PARTITION']) {
|
if (operator === OPERATORS['LATEST PARTITION']) {
|
||||||
return 'use latest_partition template';
|
return 'use latest_partition template';
|
||||||
}
|
}
|
||||||
|
@ -37,6 +37,7 @@ export const OPERATORS = {
|
|||||||
'<=': '<=',
|
'<=': '<=',
|
||||||
IN: 'IN',
|
IN: 'IN',
|
||||||
'NOT IN': 'NOT IN',
|
'NOT IN': 'NOT IN',
|
||||||
|
ILIKE: 'ILIKE',
|
||||||
LIKE: 'LIKE',
|
LIKE: 'LIKE',
|
||||||
REGEX: 'REGEX',
|
REGEX: 'REGEX',
|
||||||
'IS NOT NULL': 'IS NOT NULL',
|
'IS NOT NULL': 'IS NOT NULL',
|
||||||
@ -48,7 +49,7 @@ export const OPERATORS = {
|
|||||||
|
|
||||||
export const OPERATORS_OPTIONS = Object.values(OPERATORS);
|
export const OPERATORS_OPTIONS = Object.values(OPERATORS);
|
||||||
|
|
||||||
export const TABLE_ONLY_OPERATORS = [OPERATORS.LIKE];
|
export const TABLE_ONLY_OPERATORS = [OPERATORS.LIKE, OPERATORS.ILIKE];
|
||||||
export const DRUID_ONLY_OPERATORS = [OPERATORS.REGEX];
|
export const DRUID_ONLY_OPERATORS = [OPERATORS.REGEX];
|
||||||
export const HAVING_OPERATORS = [
|
export const HAVING_OPERATORS = [
|
||||||
OPERATORS['=='],
|
OPERATORS['=='],
|
||||||
|
@ -18,31 +18,76 @@
|
|||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
AppSection,
|
AppSection,
|
||||||
|
DataMask,
|
||||||
ensureIsArray,
|
ensureIsArray,
|
||||||
|
ExtraFormData,
|
||||||
GenericDataType,
|
GenericDataType,
|
||||||
|
JsonObject,
|
||||||
smartDateDetailedFormatter,
|
smartDateDetailedFormatter,
|
||||||
t,
|
t,
|
||||||
tn,
|
tn,
|
||||||
} from '@superset-ui/core';
|
} from '@superset-ui/core';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useReducer, useState } from 'react';
|
||||||
import { Select } from 'src/common/components';
|
import { Select } from 'src/common/components';
|
||||||
import { FIRST_VALUE, PluginFilterSelectProps, SelectValue } from './types';
|
import { debounce } from 'lodash';
|
||||||
|
import { SLOW_DEBOUNCE } from 'src/constants';
|
||||||
|
import { PluginFilterSelectProps, SelectValue } from './types';
|
||||||
import { StyledSelect, Styles } from '../common';
|
import { StyledSelect, Styles } from '../common';
|
||||||
import { getDataRecordFormatter, getSelectExtraFormData } from '../../utils';
|
import { getDataRecordFormatter, getSelectExtraFormData } from '../../utils';
|
||||||
|
|
||||||
const { Option } = Select;
|
const { Option } = Select;
|
||||||
|
|
||||||
|
type DataMaskAction =
|
||||||
|
| { type: 'ownState'; ownState: JsonObject }
|
||||||
|
| {
|
||||||
|
type: 'filterState';
|
||||||
|
extraFormData: ExtraFormData;
|
||||||
|
filterState: { value: SelectValue };
|
||||||
|
};
|
||||||
|
|
||||||
|
function reducer(state: DataMask, action: DataMaskAction): DataMask {
|
||||||
|
switch (action.type) {
|
||||||
|
case 'ownState':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
ownState: {
|
||||||
|
...(state.ownState || {}),
|
||||||
|
...action.ownState,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
case 'filterState':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
extraFormData: action.extraFormData,
|
||||||
|
filterState: {
|
||||||
|
...(state.filterState || {}),
|
||||||
|
...action.filterState,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type DataMaskReducer = (
|
||||||
|
prevState: DataMask,
|
||||||
|
action: DataMaskAction,
|
||||||
|
) => DataMask;
|
||||||
|
|
||||||
export default function PluginFilterSelect(props: PluginFilterSelectProps) {
|
export default function PluginFilterSelect(props: PluginFilterSelectProps) {
|
||||||
const {
|
const {
|
||||||
coltypeMap,
|
coltypeMap,
|
||||||
data,
|
data,
|
||||||
|
filterState,
|
||||||
formData,
|
formData,
|
||||||
height,
|
height,
|
||||||
|
isRefreshing,
|
||||||
width,
|
width,
|
||||||
setDataMask,
|
setDataMask,
|
||||||
setFocusedFilter,
|
setFocusedFilter,
|
||||||
unsetFocusedFilter,
|
unsetFocusedFilter,
|
||||||
filterState,
|
|
||||||
appSection,
|
appSection,
|
||||||
} = props;
|
} = props;
|
||||||
const {
|
const {
|
||||||
@ -53,33 +98,75 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
|
|||||||
inverseSelection,
|
inverseSelection,
|
||||||
inputRef,
|
inputRef,
|
||||||
defaultToFirstItem,
|
defaultToFirstItem,
|
||||||
|
searchAllOptions,
|
||||||
} = formData;
|
} = formData;
|
||||||
|
|
||||||
const forceFirstValue =
|
const isDisabled =
|
||||||
appSection === AppSection.FILTER_CONFIG_MODAL && defaultToFirstItem;
|
appSection === AppSection.FILTER_CONFIG_MODAL && defaultToFirstItem;
|
||||||
|
|
||||||
const groupby = ensureIsArray<string>(formData.groupby);
|
const groupby = ensureIsArray<string>(formData.groupby);
|
||||||
// Correct initial value for Ant Select
|
// Correct initial value for Ant Select
|
||||||
const initSelectValue: SelectValue =
|
|
||||||
// `defaultValue` can be `FIRST_VALUE` if `defaultToFirstItem` is checked, so need convert it to correct value for Select
|
|
||||||
defaultValue === FIRST_VALUE ? [] : defaultValue ?? [];
|
|
||||||
|
|
||||||
const firstItem: SelectValue = data[0]
|
|
||||||
? (groupby.map(col => data[0][col]) as string[]) ?? initSelectValue
|
|
||||||
: initSelectValue;
|
|
||||||
|
|
||||||
// If we are in config modal we always need show empty select for `defaultToFirstItem`
|
// If we are in config modal we always need show empty select for `defaultToFirstItem`
|
||||||
const [values, setValues] = useState<SelectValue>(
|
const [values, setValues] = useState<SelectValue>(
|
||||||
defaultToFirstItem && appSection !== AppSection.FILTER_CONFIG_MODAL
|
!isDisabled && defaultValue?.length ? defaultValue : [],
|
||||||
? firstItem
|
|
||||||
: initSelectValue,
|
|
||||||
);
|
);
|
||||||
const [currentSuggestionSearch, setCurrentSuggestionSearch] = useState('');
|
const [currentSuggestionSearch, setCurrentSuggestionSearch] = useState('');
|
||||||
|
const [dataMask, dispatchDataMask] = useReducer<DataMaskReducer>(
|
||||||
|
reducer,
|
||||||
|
searchAllOptions
|
||||||
|
? {
|
||||||
|
ownState: {
|
||||||
|
coltypeMap,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {},
|
||||||
|
);
|
||||||
|
|
||||||
|
const debouncedOwnStateFunc = useCallback(
|
||||||
|
debounce((val: string) => {
|
||||||
|
dispatchDataMask({
|
||||||
|
type: 'ownState',
|
||||||
|
ownState: {
|
||||||
|
search: val,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, SLOW_DEBOUNCE),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const searchWrapper = (val: string) => {
|
||||||
|
if (searchAllOptions) {
|
||||||
|
debouncedOwnStateFunc(val);
|
||||||
|
}
|
||||||
|
setCurrentSuggestionSearch(val);
|
||||||
|
};
|
||||||
|
|
||||||
const clearSuggestionSearch = () => {
|
const clearSuggestionSearch = () => {
|
||||||
setCurrentSuggestionSearch('');
|
setCurrentSuggestionSearch('');
|
||||||
|
if (searchAllOptions) {
|
||||||
|
dispatchDataMask({
|
||||||
|
type: 'ownState',
|
||||||
|
ownState: {
|
||||||
|
search: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const firstItem: SelectValue = data[0]
|
||||||
|
? (groupby.map(col => data[0][col]) as string[])
|
||||||
|
: null;
|
||||||
|
if (!isDisabled && defaultToFirstItem && firstItem) {
|
||||||
|
// initialize to first value if set to default to first item
|
||||||
|
setValues(firstItem);
|
||||||
|
} else if (!isDisabled && defaultValue?.length) {
|
||||||
|
// initialize to saved value
|
||||||
|
setValues(defaultValue);
|
||||||
|
}
|
||||||
|
}, [defaultToFirstItem, defaultValue]);
|
||||||
|
|
||||||
const handleBlur = () => {
|
const handleBlur = () => {
|
||||||
clearSuggestionSearch();
|
clearSuggestionSearch();
|
||||||
unsetFocusedFilter();
|
unsetFocusedFilter();
|
||||||
@ -92,27 +179,24 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const handleChange = (value?: SelectValue | number | string) => {
|
const handleChange = (value?: SelectValue | number | string) => {
|
||||||
let selectValue: (number | string)[] = ensureIsArray<number | string>(
|
setValues(ensureIsArray(value));
|
||||||
value,
|
};
|
||||||
);
|
|
||||||
let stateValue: SelectValue | typeof FIRST_VALUE = selectValue.length
|
|
||||||
? selectValue
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (value === FIRST_VALUE) {
|
useEffect(() => {
|
||||||
selectValue = forceFirstValue ? [] : firstItem;
|
if (isDisabled) {
|
||||||
stateValue = FIRST_VALUE;
|
setValues([]);
|
||||||
}
|
}
|
||||||
|
}, [isDisabled]);
|
||||||
|
|
||||||
setValues(selectValue);
|
useEffect(() => {
|
||||||
|
|
||||||
const emptyFilter =
|
const emptyFilter =
|
||||||
enableEmptyFilter && !inverseSelection && selectValue?.length === 0;
|
enableEmptyFilter && !inverseSelection && values?.length === 0;
|
||||||
|
|
||||||
setDataMask({
|
dispatchDataMask({
|
||||||
|
type: 'filterState',
|
||||||
extraFormData: getSelectExtraFormData(
|
extraFormData: getSelectExtraFormData(
|
||||||
col,
|
col,
|
||||||
selectValue,
|
values,
|
||||||
emptyFilter,
|
emptyFilter,
|
||||||
inverseSelection,
|
inverseSelection,
|
||||||
),
|
),
|
||||||
@ -120,33 +204,21 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
|
|||||||
// We need to save in state `FIRST_VALUE` as some const and not as REAL value,
|
// We need to save in state `FIRST_VALUE` as some const and not as REAL value,
|
||||||
// because when FiltersBar check if all filters initialized it compares `defaultValue` with this value
|
// because when FiltersBar check if all filters initialized it compares `defaultValue` with this value
|
||||||
// and because REAL value can be unpredictable for users that have different data for same dashboard we use `FIRST_VALUE`
|
// and because REAL value can be unpredictable for users that have different data for same dashboard we use `FIRST_VALUE`
|
||||||
value: stateValue,
|
value: values,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
}, [col, enableEmptyFilter, inverseSelection, JSON.stringify(values)]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// For currentValue we need set always `FIRST_VALUE` only if we in config modal for `defaultToFirstItem` mode
|
// handle changes coming from application, e.g. "Clear all" button
|
||||||
handleChange(forceFirstValue ? FIRST_VALUE : filterState.value ?? []);
|
if (JSON.stringify(values) !== JSON.stringify(filterState.value)) {
|
||||||
}, [
|
handleChange(filterState.value);
|
||||||
JSON.stringify(filterState.value),
|
}
|
||||||
defaultToFirstItem,
|
}, [JSON.stringify(filterState.value)]);
|
||||||
multiSelect,
|
|
||||||
enableEmptyFilter,
|
|
||||||
inverseSelection,
|
|
||||||
]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// If we have `defaultToFirstItem` mode it means that default value always `FIRST_VALUE`
|
setDataMask(dataMask);
|
||||||
handleChange(defaultToFirstItem ? FIRST_VALUE : defaultValue);
|
}, [JSON.stringify(dataMask)]);
|
||||||
}, [
|
|
||||||
JSON.stringify(defaultValue),
|
|
||||||
JSON.stringify(firstItem),
|
|
||||||
defaultToFirstItem,
|
|
||||||
multiSelect,
|
|
||||||
enableEmptyFilter,
|
|
||||||
inverseSelection,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const placeholderText =
|
const placeholderText =
|
||||||
data.length === 0
|
data.length === 0
|
||||||
@ -158,17 +230,18 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
|
|||||||
allowClear={!enableEmptyFilter}
|
allowClear={!enableEmptyFilter}
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
value={values}
|
value={values}
|
||||||
disabled={forceFirstValue}
|
disabled={isDisabled}
|
||||||
showSearch={showSearch}
|
showSearch={showSearch}
|
||||||
mode={multiSelect ? 'multiple' : undefined}
|
mode={multiSelect ? 'multiple' : undefined}
|
||||||
placeholder={placeholderText}
|
placeholder={placeholderText}
|
||||||
onSearch={setCurrentSuggestionSearch}
|
onSearch={searchWrapper}
|
||||||
onSelect={clearSuggestionSearch}
|
onSelect={clearSuggestionSearch}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
onFocus={setFocusedFilter}
|
onFocus={setFocusedFilter}
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
|
loading={isRefreshing}
|
||||||
>
|
>
|
||||||
{data.map(row => {
|
{data.map(row => {
|
||||||
const [value] = groupby.map(col => row[col]);
|
const [value] = groupby.map(col => row[col]);
|
||||||
|
@ -16,10 +16,12 @@
|
|||||||
* specific language governing permissions and limitations
|
* specific language governing permissions and limitations
|
||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
|
import { GenericDataType } from '@superset-ui/core';
|
||||||
import buildQuery from './buildQuery';
|
import buildQuery from './buildQuery';
|
||||||
|
import { PluginFilterSelectQueryFormData } from './types';
|
||||||
|
|
||||||
describe('Select buildQuery', () => {
|
describe('Select buildQuery', () => {
|
||||||
const formData = {
|
const formData: PluginFilterSelectQueryFormData = {
|
||||||
datasource: '5__table',
|
datasource: '5__table',
|
||||||
groupby: ['my_col'],
|
groupby: ['my_col'],
|
||||||
viz_type: 'filter_select',
|
viz_type: 'filter_select',
|
||||||
@ -30,6 +32,7 @@ describe('Select buildQuery', () => {
|
|||||||
inverseSelection: false,
|
inverseSelection: false,
|
||||||
multiSelect: false,
|
multiSelect: false,
|
||||||
defaultToFirstItem: false,
|
defaultToFirstItem: false,
|
||||||
|
searchAllOptions: false,
|
||||||
height: 100,
|
height: 100,
|
||||||
width: 100,
|
width: 100,
|
||||||
};
|
};
|
||||||
@ -39,6 +42,7 @@ describe('Select buildQuery', () => {
|
|||||||
expect(queryContext.queries.length).toEqual(1);
|
expect(queryContext.queries.length).toEqual(1);
|
||||||
const [query] = queryContext.queries;
|
const [query] = queryContext.queries;
|
||||||
expect(query.groupby).toEqual(['my_col']);
|
expect(query.groupby).toEqual(['my_col']);
|
||||||
|
expect(query.filters).toEqual([{ col: 'my_col', op: 'IS NOT NULL' }]);
|
||||||
expect(query.metrics).toEqual([]);
|
expect(query.metrics).toEqual([]);
|
||||||
expect(query.apply_fetch_values_predicate).toEqual(true);
|
expect(query.apply_fetch_values_predicate).toEqual(true);
|
||||||
expect(query.orderby).toEqual([]);
|
expect(query.orderby).toEqual([]);
|
||||||
@ -56,4 +60,30 @@ describe('Select buildQuery', () => {
|
|||||||
expect(query.metrics).toEqual(['my_metric']);
|
expect(query.metrics).toEqual(['my_metric']);
|
||||||
expect(query.orderby).toEqual([['my_metric', false]]);
|
expect(query.orderby).toEqual([['my_metric', false]]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should add text search parameter to query filter', () => {
|
||||||
|
const queryContext = buildQuery(formData, {
|
||||||
|
ownState: {
|
||||||
|
search: 'abc',
|
||||||
|
coltypeMap: { my_col: GenericDataType.STRING },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(queryContext.queries.length).toEqual(1);
|
||||||
|
const [query] = queryContext.queries;
|
||||||
|
expect(query.filters).toEqual([
|
||||||
|
{ col: 'my_col', op: 'ILIKE', val: '%abc%' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add numeric search parameter to query filter', () => {
|
||||||
|
const queryContext = buildQuery(formData, {
|
||||||
|
ownState: {
|
||||||
|
search: '123',
|
||||||
|
coltypeMap: { my_col: GenericDataType.NUMERIC },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(queryContext.queries.length).toEqual(1);
|
||||||
|
const [query] = queryContext.queries;
|
||||||
|
expect(query.filters).toEqual([{ col: 'my_col', op: '>=', val: 123 }]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -16,29 +16,63 @@
|
|||||||
* specific language governing permissions and limitations
|
* specific language governing permissions and limitations
|
||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import { buildQueryContext } from '@superset-ui/core';
|
import {
|
||||||
|
buildQueryContext,
|
||||||
|
GenericDataType,
|
||||||
|
QueryObject,
|
||||||
|
QueryObjectFilterClause,
|
||||||
|
} from '@superset-ui/core';
|
||||||
|
import { BuildQuery } from '@superset-ui/core/lib/chart/registries/ChartBuildQueryRegistrySingleton';
|
||||||
import { DEFAULT_FORM_DATA, PluginFilterSelectQueryFormData } from './types';
|
import { DEFAULT_FORM_DATA, PluginFilterSelectQueryFormData } from './types';
|
||||||
|
|
||||||
export default function buildQuery(formData: PluginFilterSelectQueryFormData) {
|
const buildQuery: BuildQuery<PluginFilterSelectQueryFormData> = (
|
||||||
|
formData: PluginFilterSelectQueryFormData,
|
||||||
|
options,
|
||||||
|
) => {
|
||||||
|
const { search, coltypeMap } = options?.ownState || {};
|
||||||
const { sortAscending, sortMetric } = { ...DEFAULT_FORM_DATA, ...formData };
|
const { sortAscending, sortMetric } = { ...DEFAULT_FORM_DATA, ...formData };
|
||||||
return buildQueryContext(formData, baseQueryObject => {
|
return buildQueryContext(formData, baseQueryObject => {
|
||||||
const { columns = [], filters = [] } = baseQueryObject;
|
const { columns = [], filters = [] } = baseQueryObject;
|
||||||
|
const extra_filters: QueryObjectFilterClause[] = columns.map(column => {
|
||||||
|
if (search && coltypeMap[column] === GenericDataType.STRING) {
|
||||||
|
return {
|
||||||
|
col: column,
|
||||||
|
op: 'ILIKE',
|
||||||
|
val: `%${search}%`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
search &&
|
||||||
|
coltypeMap[column] === GenericDataType.NUMERIC &&
|
||||||
|
!Number.isNaN(Number(search))
|
||||||
|
) {
|
||||||
|
// for numeric columns we apply a >= where clause
|
||||||
|
return {
|
||||||
|
col: column,
|
||||||
|
op: '>=',
|
||||||
|
val: Number(search),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// if no search is defined, make sure the col value is not null
|
||||||
|
return { col: column, op: 'IS NOT NULL' };
|
||||||
|
});
|
||||||
|
|
||||||
const sortColumns = sortMetric ? [sortMetric] : columns;
|
const sortColumns = sortMetric ? [sortMetric] : columns;
|
||||||
return [
|
const query: QueryObject[] = [
|
||||||
{
|
{
|
||||||
...baseQueryObject,
|
...baseQueryObject,
|
||||||
apply_fetch_values_predicate: true,
|
apply_fetch_values_predicate: true,
|
||||||
groupby: columns,
|
groupby: columns,
|
||||||
metrics: sortMetric ? [sortMetric] : [],
|
metrics: sortMetric ? [sortMetric] : [],
|
||||||
filters: filters.concat(
|
filters: filters.concat(extra_filters),
|
||||||
columns.map(column => ({ col: column, op: 'IS NOT NULL' })),
|
|
||||||
),
|
|
||||||
orderby:
|
orderby:
|
||||||
sortMetric || sortAscending
|
sortMetric || sortAscending
|
||||||
? sortColumns.map(column => [column, sortAscending])
|
? sortColumns.map(column => [column, sortAscending])
|
||||||
: [],
|
: [],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
return query;
|
||||||
});
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
|
export default buildQuery;
|
||||||
|
@ -25,6 +25,7 @@ const {
|
|||||||
inverseSelection,
|
inverseSelection,
|
||||||
multiSelect,
|
multiSelect,
|
||||||
defaultToFirstItem,
|
defaultToFirstItem,
|
||||||
|
searchAllOptions,
|
||||||
sortAscending,
|
sortAscending,
|
||||||
} = DEFAULT_FORM_DATA;
|
} = DEFAULT_FORM_DATA;
|
||||||
|
|
||||||
@ -109,6 +110,23 @@ const config: ControlPanelConfig = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
name: 'searchAllOptions',
|
||||||
|
config: {
|
||||||
|
type: 'CheckboxControl',
|
||||||
|
renderTrigger: true,
|
||||||
|
affectsDataMask: true,
|
||||||
|
label: t('Search all filter options'),
|
||||||
|
default: searchAllOptions,
|
||||||
|
description: t(
|
||||||
|
'By default, each filter loads at most 1000 choices at the initial page load. ' +
|
||||||
|
'Check this box if you have more than 1000 filter values and want to enable dynamically ' +
|
||||||
|
'searching that loads filter values as users type (may add stress to your database).',
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
@ -28,6 +28,7 @@ export default class FilterSelectPlugin extends ChartPlugin {
|
|||||||
name: t('Select filter'),
|
name: t('Select filter'),
|
||||||
description: t('Select filter plugin using AntD'),
|
description: t('Select filter plugin using AntD'),
|
||||||
behaviors: [Behavior.INTERACTIVE_CHART, Behavior.NATIVE_FILTER],
|
behaviors: [Behavior.INTERACTIVE_CHART, Behavior.NATIVE_FILTER],
|
||||||
|
enableNoResults: false,
|
||||||
thumbnail,
|
thumbnail,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -31,6 +31,7 @@ export default function transformProps(
|
|||||||
behaviors,
|
behaviors,
|
||||||
appSection,
|
appSection,
|
||||||
filterState,
|
filterState,
|
||||||
|
isRefreshing,
|
||||||
} = chartProps;
|
} = chartProps;
|
||||||
const newFormData = { ...DEFAULT_FORM_DATA, ...formData };
|
const newFormData = { ...DEFAULT_FORM_DATA, ...formData };
|
||||||
const {
|
const {
|
||||||
@ -54,6 +55,7 @@ export default function transformProps(
|
|||||||
height,
|
height,
|
||||||
data,
|
data,
|
||||||
formData: newFormData,
|
formData: newFormData,
|
||||||
|
isRefreshing,
|
||||||
setDataMask,
|
setDataMask,
|
||||||
setFocusedFilter,
|
setFocusedFilter,
|
||||||
unsetFocusedFilter,
|
unsetFocusedFilter,
|
||||||
|
@ -29,16 +29,16 @@ import {
|
|||||||
import { RefObject } from 'react';
|
import { RefObject } from 'react';
|
||||||
import { PluginFilterHooks, PluginFilterStylesProps } from '../types';
|
import { PluginFilterHooks, PluginFilterStylesProps } from '../types';
|
||||||
|
|
||||||
export const FIRST_VALUE = '__FIRST_VALUE__';
|
|
||||||
export type SelectValue = (number | string)[] | null;
|
export type SelectValue = (number | string)[] | null;
|
||||||
|
|
||||||
interface PluginFilterSelectCustomizeProps {
|
interface PluginFilterSelectCustomizeProps {
|
||||||
defaultValue?: SelectValue | typeof FIRST_VALUE;
|
defaultValue?: SelectValue;
|
||||||
enableEmptyFilter: boolean;
|
enableEmptyFilter: boolean;
|
||||||
inverseSelection: boolean;
|
inverseSelection: boolean;
|
||||||
multiSelect: boolean;
|
multiSelect: boolean;
|
||||||
defaultToFirstItem: boolean;
|
defaultToFirstItem: boolean;
|
||||||
inputRef?: RefObject<HTMLInputElement>;
|
inputRef?: RefObject<HTMLInputElement>;
|
||||||
|
searchAllOptions: boolean;
|
||||||
sortAscending: boolean;
|
sortAscending: boolean;
|
||||||
sortMetric?: string;
|
sortMetric?: string;
|
||||||
}
|
}
|
||||||
@ -58,6 +58,7 @@ export type PluginFilterSelectProps = PluginFilterStylesProps & {
|
|||||||
appSection: AppSection;
|
appSection: AppSection;
|
||||||
formData: PluginFilterSelectQueryFormData;
|
formData: PluginFilterSelectQueryFormData;
|
||||||
filterState: FilterState;
|
filterState: FilterState;
|
||||||
|
isRefreshing: boolean;
|
||||||
} & PluginFilterHooks;
|
} & PluginFilterHooks;
|
||||||
|
|
||||||
export const DEFAULT_FORM_DATA: PluginFilterSelectCustomizeProps = {
|
export const DEFAULT_FORM_DATA: PluginFilterSelectCustomizeProps = {
|
||||||
@ -66,5 +67,6 @@ export const DEFAULT_FORM_DATA: PluginFilterSelectCustomizeProps = {
|
|||||||
inverseSelection: false,
|
inverseSelection: false,
|
||||||
defaultToFirstItem: false,
|
defaultToFirstItem: false,
|
||||||
multiSelect: true,
|
multiSelect: true,
|
||||||
|
searchAllOptions: false,
|
||||||
sortAscending: true,
|
sortAscending: true,
|
||||||
};
|
};
|
||||||
|
@ -1239,6 +1239,8 @@ class SqlaTable( # pylint: disable=too-many-public-methods,too-many-instance-at
|
|||||||
where_clause_and.append(col_obj.get_sqla_col() <= eq)
|
where_clause_and.append(col_obj.get_sqla_col() <= eq)
|
||||||
elif op == utils.FilterOperator.LIKE.value:
|
elif op == utils.FilterOperator.LIKE.value:
|
||||||
where_clause_and.append(col_obj.get_sqla_col().like(eq))
|
where_clause_and.append(col_obj.get_sqla_col().like(eq))
|
||||||
|
elif op == utils.FilterOperator.ILIKE.value:
|
||||||
|
where_clause_and.append(col_obj.get_sqla_col().ilike(eq))
|
||||||
else:
|
else:
|
||||||
raise QueryObjectValidationError(
|
raise QueryObjectValidationError(
|
||||||
_("Invalid filter operation type: %(op)s", op=op)
|
_("Invalid filter operation type: %(op)s", op=op)
|
||||||
|
@ -206,6 +206,7 @@ class FilterOperator(str, Enum):
|
|||||||
GREATER_THAN_OR_EQUALS = ">="
|
GREATER_THAN_OR_EQUALS = ">="
|
||||||
LESS_THAN_OR_EQUALS = "<="
|
LESS_THAN_OR_EQUALS = "<="
|
||||||
LIKE = "LIKE"
|
LIKE = "LIKE"
|
||||||
|
ILIKE = "ILIKE"
|
||||||
IS_NULL = "IS NULL"
|
IS_NULL = "IS NULL"
|
||||||
IS_NOT_NULL = "IS NOT NULL"
|
IS_NOT_NULL = "IS NOT NULL"
|
||||||
IN = "IN" # pylint: disable=invalid-name
|
IN = "IN" # pylint: disable=invalid-name
|
||||||
|
Loading…
Reference in New Issue
Block a user