feat(dashboard): add Rison-encoded URL filter support

Adds ?f=(...) URL parameter support for hydrating dashboard filters from
human-readable Rison syntax. Matches URL filters to native filters by column
name (case-insensitive, handles spaces) and auto-applies them by populating
both filterState.value and extraFormData. Unmatched filters render as
removable chips in a "URL Filters" section above cross-filters.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Hugh A Miles II
2026-04-30 18:31:05 -04:00
parent 3e25f02da9
commit 16e83455e9
12 changed files with 1694 additions and 14 deletions

View File

@@ -17,20 +17,31 @@
* under the License.
*/
import { FC, memo, useMemo } from 'react';
import { FC, memo, useCallback, useMemo } from 'react';
import { t } from '@apache-superset/core/translation';
import { DataMaskStateWithId } from '@superset-ui/core';
import { styled } from '@apache-superset/core/theme';
import { Loading } from '@superset-ui/core/components';
import { RootState } from 'src/dashboard/types';
import { Icons } from '@superset-ui/core/components/Icons';
import { FilterBarOrientation, RootState } from 'src/dashboard/types';
import { useChartLayoutItems } from 'src/dashboard/util/useChartLayoutItems';
import { useChartIds } from 'src/dashboard/util/charts/useChartIds';
import { useSelector } from 'react-redux';
import {
getRisonFilterParam,
parseRisonFilters,
updateUrlWithUnmatchedFilters,
} from 'src/dashboard/util/risonFilters';
import FilterControls from './FilterControls/FilterControls';
import { useChartsVerboseMaps, getFilterBarTestId } from './utils';
import { HorizontalBarProps } from './types';
import FilterBarSettings from './FilterBarSettings';
import crossFiltersSelector from './CrossFilters/selectors';
import {
getUrlFilterIndicators,
UrlFilterIndicator,
} from './UrlFilters/selectors';
import UrlFilterTag from './UrlFilters/UrlFilterTag';
const HorizontalBar = styled.div`
${({ theme }) => `
@@ -65,6 +76,28 @@ const FilterBarEmptyStateContainer = styled.div`
`}
`;
const UrlFiltersContainer = styled.div`
${({ theme }) => `
display: flex;
flex-direction: row;
align-items: center;
gap: ${theme.sizeUnit * 2}px;
padding: 0 ${theme.sizeUnit * 2}px;
margin-right: ${theme.sizeUnit * 2}px;
border-right: 1px solid ${theme.colorBorder};
`}
`;
const UrlFilterTitle = styled.div`
${({ theme }) => `
display: flex;
align-items: center;
gap: ${theme.sizeUnit}px;
font-weight: ${theme.fontWeightStrong};
font-size: ${theme.fontSizeSM}px;
`}
`;
const HorizontalFilterBar: FC<HorizontalBarProps> = ({
actions,
dataMaskSelected,
@@ -94,9 +127,47 @@ const HorizontalFilterBar: FC<HorizontalBarProps> = ({
[chartIds, chartLayoutItems, dataMask, verboseMaps],
);
const activeUrlFilters = useMemo(() => getUrlFilterIndicators(), []);
const handleRemoveUrlFilter = useCallback(
(filterToRemove: UrlFilterIndicator) => {
const risonParam = getRisonFilterParam();
if (!risonParam) return;
const currentFilters = parseRisonFilters(risonParam);
const remaining = currentFilters.filter(
f => f.subject !== filterToRemove.filter.subject,
);
updateUrlWithUnmatchedFilters(remaining);
},
[],
);
const urlFiltersComponent = useMemo(() => {
if (activeUrlFilters.length === 0) return null;
return (
<UrlFiltersContainer>
<UrlFilterTitle>
<Icons.LinkOutlined iconSize="s" />
{t('URL Filters')}
</UrlFilterTitle>
{activeUrlFilters.map(filter => (
<UrlFilterTag
key={filter.subject}
filter={filter}
orientation={FilterBarOrientation.Horizontal}
onRemove={handleRemoveUrlFilter}
/>
))}
</UrlFiltersContainer>
);
}, [activeUrlFilters, handleRemoveUrlFilter]);
const hasFilters =
filterValues.length > 0 ||
selectedCrossFilters.length > 0 ||
activeUrlFilters.length > 0 ||
chartCustomizationValues.length > 0;
return (
@@ -113,16 +184,19 @@ const HorizontalFilterBar: FC<HorizontalBarProps> = ({
</FilterBarEmptyStateContainer>
)}
{hasFilters && (
<FilterControls
dataMaskSelected={dataMaskSelected}
onFilterSelectionChange={onSelectionChange}
onPendingCustomizationDataMaskChange={
onPendingCustomizationDataMaskChange
}
chartCustomizationValues={chartCustomizationValues}
clearAllTriggers={clearAllTriggers}
onClearAllComplete={onClearAllComplete}
/>
<>
{urlFiltersComponent}
<FilterControls
dataMaskSelected={dataMaskSelected}
onFilterSelectionChange={onSelectionChange}
onPendingCustomizationDataMaskChange={
onPendingCustomizationDataMaskChange
}
chartCustomizationValues={chartCustomizationValues}
clearAllTriggers={clearAllTriggers}
onClearAllComplete={onClearAllComplete}
/>
</>
)}
{actions}
</>

View File

@@ -0,0 +1,85 @@
/**
* 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 { useCSSTextTruncation } from '@superset-ui/core';
import { styled, css, useTheme } from '@apache-superset/core/theme';
import { Tag } from 'src/components/Tag';
import { Tooltip } from '@superset-ui/core/components';
import { FilterBarOrientation } from 'src/dashboard/types';
import { ellipsisCss } from '../CrossFilters/styles';
import { UrlFilterIndicator } from './selectors';
const StyledValue = styled.b`
${({ theme }) => `
max-width: ${theme.sizeUnit * 25}px;
`}
${ellipsisCss}
`;
const StyledColumn = styled('span')`
${({ theme }) => `
max-width: ${theme.sizeUnit * 25}px;
padding-right: ${theme.sizeUnit}px;
`}
${ellipsisCss}
`;
const StyledTag = styled(Tag)`
${({ theme }) => `
border: 1px solid ${theme.colorBorder};
border-radius: 2px;
.anticon-close {
vertical-align: middle;
}
`}
`;
const UrlFilterTag = (props: {
filter: UrlFilterIndicator;
orientation: FilterBarOrientation;
onRemove: (filter: UrlFilterIndicator) => void;
}) => {
const { filter, orientation, onRemove } = props;
const theme = useTheme();
const [columnRef, columnIsTruncated] =
useCSSTextTruncation<HTMLSpanElement>();
const [valueRef, valueIsTruncated] = useCSSTextTruncation<HTMLSpanElement>();
return (
<StyledTag
css={css`
${orientation === FilterBarOrientation.Vertical
? `margin-top: ${theme.sizeUnit * 2}px;`
: `margin-left: ${theme.sizeUnit * 2}px;`}
`}
closable
onClose={() => onRemove(filter)}
editable
>
<Tooltip title={columnIsTruncated ? filter.subject : null}>
<StyledColumn ref={columnRef}>{filter.subject}</StyledColumn>
</Tooltip>
<Tooltip title={valueIsTruncated ? filter.value : null}>
<StyledValue ref={valueRef}>{filter.value}</StyledValue>
</Tooltip>
</StyledTag>
);
};
export default UrlFilterTag;

View File

@@ -0,0 +1,34 @@
/**
* 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 { useMemo } from 'react';
import { getUrlFilterIndicators } from './selectors';
import UrlFiltersVerticalCollapse from './VerticalCollapse';
const UrlFiltersVertical = () => {
const urlFilters = useMemo(() => getUrlFilterIndicators(), []);
if (!urlFilters.length) {
return null;
}
return <UrlFiltersVerticalCollapse urlFilters={urlFilters} />;
};
export default UrlFiltersVertical;

View File

@@ -0,0 +1,173 @@
/**
* 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 { useMemo, useState, useCallback } from 'react';
import { t } from '@apache-superset/core/translation';
import { css, useTheme, SupersetTheme } from '@apache-superset/core/theme';
import { Icons } from '@superset-ui/core/components/Icons';
import { FilterBarOrientation } from 'src/dashboard/types';
import {
updateUrlWithUnmatchedFilters,
getRisonFilterParam,
parseRisonFilters,
} from 'src/dashboard/util/risonFilters';
import UrlFilterTag from './UrlFilterTag';
import { UrlFilterIndicator } from './selectors';
const UrlFiltersVerticalCollapse = (props: {
urlFilters: UrlFilterIndicator[];
}) => {
const { urlFilters: initialFilters } = props;
const theme = useTheme();
const [isOpen, setIsOpen] = useState(true);
const [urlFilters, setUrlFilters] =
useState<UrlFilterIndicator[]>(initialFilters);
const toggleSection = useCallback(() => {
setIsOpen(prev => !prev);
}, []);
const handleRemoveFilter = useCallback(
(filterToRemove: UrlFilterIndicator) => {
const risonParam = getRisonFilterParam();
if (!risonParam) return;
const currentFilters = parseRisonFilters(risonParam);
const remaining = currentFilters.filter(
f => f.subject !== filterToRemove.filter.subject,
);
updateUrlWithUnmatchedFilters(remaining);
setUrlFilters(prev =>
prev.filter(f => f.subject !== filterToRemove.subject),
);
},
[],
);
const sectionContainerStyle = useCallback(
(theme: SupersetTheme) => css`
margin-bottom: ${theme.sizeUnit * 3}px;
padding: 0 ${theme.sizeUnit * 4}px;
`,
[],
);
const sectionHeaderStyle = useCallback(
(theme: SupersetTheme) => css`
display: flex;
align-items: center;
justify-content: space-between;
padding: ${theme.sizeUnit * 2}px 0;
cursor: pointer;
user-select: none;
&:hover {
background: ${theme.colorBgTextHover};
margin: 0 -${theme.sizeUnit * 2}px;
padding: ${theme.sizeUnit * 2}px;
border-radius: ${theme.borderRadius}px;
}
`,
[],
);
const sectionTitleStyle = useCallback(
(theme: SupersetTheme) => css`
margin: 0;
font-size: ${theme.fontSize}px;
font-weight: ${theme.fontWeightStrong};
color: ${theme.colorText};
line-height: 1.3;
display: flex;
align-items: center;
gap: ${theme.sizeUnit}px;
`,
[],
);
const sectionContentStyle = useCallback(
(theme: SupersetTheme) => css`
padding: ${theme.sizeUnit * 2}px 0;
`,
[],
);
const dividerStyle = useCallback(
(theme: SupersetTheme) => css`
height: 1px;
background: ${theme.colorSplit};
margin: ${theme.sizeUnit * 2}px 0;
`,
[],
);
const iconStyle = useCallback(
(open: boolean, theme: SupersetTheme) => css`
transform: ${open ? 'rotate(0deg)' : 'rotate(180deg)'};
transition: transform 0.2s ease;
color: ${theme.colorTextSecondary};
`,
[],
);
const filterIndicators = useMemo(
() =>
urlFilters.map(filter => (
<UrlFilterTag
key={filter.subject}
filter={filter}
orientation={FilterBarOrientation.Vertical}
onRemove={handleRemoveFilter}
/>
)),
[urlFilters, handleRemoveFilter],
);
if (!urlFilters.length) {
return null;
}
return (
<div css={sectionContainerStyle}>
<div
css={sectionHeaderStyle}
onClick={toggleSection}
onKeyDown={e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggleSection();
}
}}
role="button"
tabIndex={0}
>
<h4 css={sectionTitleStyle}>
<Icons.LinkOutlined iconSize="s" />
{t('URL Filters')}
</h4>
<Icons.UpOutlined iconSize="m" css={iconStyle(isOpen, theme)} />
</div>
{isOpen && <div css={sectionContentStyle}>{filterIndicators}</div>}
{isOpen && <div css={dividerStyle} data-test="url-filters-divider" />}
</div>
);
};
export default UrlFiltersVerticalCollapse;

View File

@@ -0,0 +1,60 @@
/**
* 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 {
getRisonFilterParam,
parseRisonFilters,
RisonFilter,
} from 'src/dashboard/util/risonFilters';
export interface UrlFilterIndicator {
subject: string;
operator: string;
value: string;
filter: RisonFilter;
}
function formatFilterValue(filter: RisonFilter): string {
const { comparator, operator } = filter;
if (operator === 'BETWEEN' && Array.isArray(comparator)) {
return `${comparator[0]} ${comparator[1]}`;
}
if (Array.isArray(comparator)) {
return comparator.join(', ');
}
return String(comparator);
}
export function getUrlFilterIndicators(): UrlFilterIndicator[] {
const risonParam = getRisonFilterParam();
if (!risonParam) {
return [];
}
const filters = parseRisonFilters(risonParam);
return filters.map(filter => ({
subject: filter.subject,
operator: filter.operator,
value: formatFilterValue(filter),
filter,
}));
}

View File

@@ -45,6 +45,7 @@ import Header from './Header';
import FilterControls from './FilterControls/FilterControls';
import CrossFiltersVertical from './CrossFilters/Vertical';
import crossFiltersSelector from './CrossFilters/selectors';
import UrlFiltersVertical from './UrlFilters/Vertical';
enum SectionType {
Filters = 'filters',
@@ -301,6 +302,7 @@ const VerticalFilterBar: FC<VerticalBarProps> = ({
) : (
<div css={tabPaneStyle} onScroll={onScroll}>
<>
<UrlFiltersVertical />
<CrossFiltersVertical hideHeader={hasOnlyOneSectionType} />
{filterControls}
</>

View File

@@ -107,9 +107,16 @@ const publishDataMask = debounce(
const previousParams = new URLSearchParams(search);
const newParams = new URLSearchParams();
let dataMaskKey: string | null;
let risonFilterValue: string | null = null;
previousParams.forEach((value, key) => {
if (!EXCLUDED_URL_PARAMS.includes(key)) {
newParams.append(key, value);
if (key === 'f') {
// Preserve the original Rison filter value to avoid encoding
risonFilterValue = value;
} else {
newParams.append(key, value);
}
}
});
@@ -148,9 +155,16 @@ const publishDataMask = debounce(
if (appRoot !== '/' && replacementPathname.startsWith(appRoot)) {
replacementPathname = replacementPathname.substring(appRoot.length);
}
// Manually reconstruct the search string to preserve Rison filter encoding
let searchString = newParams.toString();
if (risonFilterValue) {
const separator = searchString ? '&' : '';
searchString = `${searchString}${separator}f=${risonFilterValue}`;
}
history.replace({
pathname: replacementPathname,
search: newParams.toString(),
search: searchString,
});
}
},

View File

@@ -64,6 +64,15 @@ import SyncDashboardState, {
getDashboardContextLocalStorage,
} from '../components/SyncDashboardState';
import { AutoRefreshProvider } from '../contexts/AutoRefreshContext';
import { PartialFilters } from '@superset-ui/core';
import {
parseRisonFilters,
risonToAdhocFilters,
getRisonFilterParam,
prettifyRisonFilterUrl,
injectRisonFiltersIntelligently,
updateUrlWithUnmatchedFilters,
} from '../util/risonFilters';
export const DashboardPageIdContext = createContext('');
@@ -195,6 +204,61 @@ export const DashboardPage: FC<PageProps> = ({ idOrSlug }: PageProps) => {
dataMask = isOldRison;
}
// Parse Rison URL filters with intelligent native filter injection
const risonFilterParam = getRisonFilterParam();
if (risonFilterParam) {
const risonFilters = parseRisonFilters(risonFilterParam);
if (risonFilters.length > 0) {
// Convert native filter config array to keyed object for lookup
const filterConfigArray =
(dashboard?.metadata
?.native_filter_configuration as Array<Record<string, unknown> & { id: string }>) ||
[];
const nativeFilters: PartialFilters = {};
filterConfigArray.forEach(filter => {
nativeFilters[filter.id] = filter as PartialFilters[string];
});
const injectionResult = injectRisonFiltersIntelligently(
risonFilters,
nativeFilters,
dataMask,
);
dataMask = injectionResult.updatedDataMask;
// For unmatched filters, fall back to adhoc filter approach
if (injectionResult.unmatchedFilters.length > 0) {
const unmatchedAdhocFilters = risonToAdhocFilters(
injectionResult.unmatchedFilters,
);
const risonDataMask = {
__rison_filters__: {
filterState: { value: unmatchedAdhocFilters },
ownState: {},
},
};
dataMask = { ...dataMask, ...risonDataMask };
}
// Clean up URL: remove matched filters, keep only unmatched ones
const matchedCount =
risonFilters.length - injectionResult.unmatchedFilters.length;
if (matchedCount > 0) {
setTimeout(
() =>
updateUrlWithUnmatchedFilters(injectionResult.unmatchedFilters),
100,
);
}
if (injectionResult.unmatchedFilters.length > 0) {
setTimeout(() => prettifyRisonFilterUrl(), 150);
}
}
}
if (readyToRender) {
if (!isDashboardHydrated.current) {
isDashboardHydrated.current = true;

View File

@@ -0,0 +1,325 @@
/**
* 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 { PartialFilters, DataMaskStateWithId } from '@superset-ui/core';
import {
injectRisonFiltersIntelligently,
RisonFilter,
parseRisonFilters,
risonFiltersToString,
risonToAdhocFilters,
} from './risonFilters';
const mockNativeFilters: PartialFilters = {
filter_1: {
id: 'filter_1',
targets: [
{
column: { name: 'country' },
datasetId: 1,
},
],
filterType: 'filter_select',
},
filter_2: {
id: 'filter_2',
targets: [
{
column: { name: 'year' },
datasetId: 1,
},
],
filterType: 'filter_range',
},
filter_3: {
id: 'filter_3',
targets: [
{
column: { name: 'Country Code' },
datasetId: 1,
},
],
filterType: 'filter_select',
},
};
const mockDataMask: DataMaskStateWithId = {
filter_1: {
id: 'filter_1',
filterState: { value: undefined },
ownState: {},
},
};
test('should parse simple Rison filters', () => {
const risonString = '(country:USA,year:2024)';
const result = parseRisonFilters(risonString);
expect(result).toHaveLength(2);
expect(result[0]).toEqual({
subject: 'country',
operator: '==',
comparator: 'USA',
});
expect(result[1]).toEqual({
subject: 'year',
operator: '==',
comparator: 2024,
});
});
test('should parse IN operator with array syntax', () => {
const result = parseRisonFilters('(country:!(USA,Canada))');
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
subject: 'country',
operator: 'IN',
comparator: ['USA', 'Canada'],
});
});
test('should parse BETWEEN operator', () => {
const result = parseRisonFilters('(msrp:(between:!(35,200)))');
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
subject: 'msrp',
operator: 'BETWEEN',
comparator: [35, 200],
});
});
test('should parse NOT operator', () => {
const result = parseRisonFilters('(NOT:(country:USA))');
expect(result).toHaveLength(1);
expect(result[0].operator).toBe('!=');
expect(result[0].comparator).toBe('USA');
});
test('should parse comparison operators', () => {
expect(parseRisonFilters('(sales:(gt:100000))')[0].operator).toBe('>');
expect(parseRisonFilters('(age:(gte:18))')[0].operator).toBe('>=');
expect(parseRisonFilters('(temp:(lt:32))')[0].operator).toBe('<');
expect(parseRisonFilters('(price:(lte:1000))')[0].operator).toBe('<=');
});
test('should return empty array for invalid Rison', () => {
expect(parseRisonFilters('invalid rison')).toEqual([]);
expect(parseRisonFilters('(unclosed')).toEqual([]);
});
test('should match Rison filter to native filter by column name', () => {
const risonFilters: RisonFilter[] = [
{ subject: 'country', operator: '==', comparator: 'USA' },
];
const result = injectRisonFiltersIntelligently(
risonFilters,
mockNativeFilters,
mockDataMask,
);
expect(result.updatedDataMask.filter_1.filterState?.value).toEqual(['USA']);
expect(result.unmatchedFilters).toHaveLength(0);
});
test('should match column names with spaces (case-insensitive)', () => {
const risonFilters: RisonFilter[] = [
{ subject: 'Country Code', operator: '==', comparator: 'USA' },
];
const result = injectRisonFiltersIntelligently(
risonFilters,
mockNativeFilters,
mockDataMask,
);
expect(result.updatedDataMask.filter_3.filterState?.value).toEqual(['USA']);
expect(result.unmatchedFilters).toHaveLength(0);
});
test('should match column names case-insensitively', () => {
const risonFilters: RisonFilter[] = [
{ subject: 'country code', operator: '==', comparator: 'USA' },
];
const result = injectRisonFiltersIntelligently(
risonFilters,
mockNativeFilters,
mockDataMask,
);
expect(result.updatedDataMask.filter_3.filterState?.value).toEqual(['USA']);
expect(result.unmatchedFilters).toHaveLength(0);
});
test('should handle unmatched filters with fallback', () => {
const risonFilters: RisonFilter[] = [
{ subject: 'region', operator: '==', comparator: 'North America' },
];
const result = injectRisonFiltersIntelligently(
risonFilters,
mockNativeFilters,
mockDataMask,
);
expect(result.unmatchedFilters).toHaveLength(1);
expect(result.unmatchedFilters[0].subject).toBe('region');
});
test('should convert values correctly for different filter types', () => {
const risonFilters: RisonFilter[] = [
{ subject: 'country', operator: '==', comparator: 'USA' },
{ subject: 'year', operator: 'BETWEEN', comparator: [2020, 2024] },
];
const result = injectRisonFiltersIntelligently(
risonFilters,
mockNativeFilters,
mockDataMask,
);
// Select filter should be array
expect(result.updatedDataMask.filter_1.filterState?.value).toEqual(['USA']);
// Range filter should be min/max object
expect(result.updatedDataMask.filter_2.filterState?.value).toEqual({
min: 2020,
max: 2024,
});
expect(result.unmatchedFilters).toHaveLength(0);
});
test('should set extraFormData for auto-application on select filters', () => {
const risonFilters: RisonFilter[] = [
{ subject: 'country', operator: '==', comparator: 'USA' },
];
const result = injectRisonFiltersIntelligently(
risonFilters,
mockNativeFilters,
mockDataMask,
);
expect(result.updatedDataMask.filter_1.extraFormData).toEqual({
filters: [{ col: 'country', op: 'IN', val: ['USA'] }],
});
});
test('should set extraFormData for auto-application on IN filters', () => {
const risonFilters: RisonFilter[] = [
{ subject: 'country', operator: 'IN', comparator: ['USA', 'Canada'] },
];
const result = injectRisonFiltersIntelligently(
risonFilters,
mockNativeFilters,
mockDataMask,
);
expect(result.updatedDataMask.filter_1.filterState?.value).toEqual([
'USA',
'Canada',
]);
expect(result.updatedDataMask.filter_1.extraFormData).toEqual({
filters: [{ col: 'country', op: 'IN', val: ['USA', 'Canada'] }],
});
});
test('should set extraFormData for auto-application on BETWEEN filters', () => {
const risonFilters: RisonFilter[] = [
{ subject: 'year', operator: 'BETWEEN', comparator: [2020, 2024] },
];
const result = injectRisonFiltersIntelligently(
risonFilters,
mockNativeFilters,
mockDataMask,
);
expect(result.updatedDataMask.filter_2.filterState?.value).toEqual({
min: 2020,
max: 2024,
});
expect(result.updatedDataMask.filter_2.extraFormData).toEqual({
filters: [
{ col: 'year', op: '>=', val: 2020 },
{ col: 'year', op: '<=', val: 2024 },
],
});
});
test('should handle mixed matched and unmatched filters', () => {
const risonFilters: RisonFilter[] = [
{ subject: 'country', operator: '==', comparator: 'USA' },
{ subject: 'category', operator: '==', comparator: 'Sales' },
];
const result = injectRisonFiltersIntelligently(
risonFilters,
mockNativeFilters,
mockDataMask,
);
expect(result.updatedDataMask.filter_1.filterState?.value).toEqual(['USA']);
expect(result.unmatchedFilters).toHaveLength(1);
expect(result.unmatchedFilters[0].subject).toBe('category');
});
test('should convert filters to adhoc format', () => {
const risonFilters: RisonFilter[] = [
{ subject: 'country', operator: '==', comparator: 'USA' },
];
const adhocFilters = risonToAdhocFilters(risonFilters);
expect(adhocFilters).toHaveLength(1);
expect(adhocFilters[0]).toMatchObject({
expressionType: 'SIMPLE',
clause: 'WHERE',
subject: 'country',
operator: '==',
comparator: 'USA',
});
});
test('should convert filters to Rison string', () => {
const filters: RisonFilter[] = [
{ subject: 'country', operator: '==', comparator: 'USA' },
];
const result = risonFiltersToString(filters);
expect(result).toBe('(country:USA)');
});
test('should convert IN filters to Rison string', () => {
const filters: RisonFilter[] = [
{ subject: 'country', operator: 'IN', comparator: ['USA', 'Canada'] },
];
const result = risonFiltersToString(filters);
expect(result).toBe('(country:!(USA,Canada))');
});
test('should return empty string for empty filters', () => {
expect(risonFiltersToString([])).toBe('');
});

View File

@@ -0,0 +1,490 @@
/**
* 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 {
QueryObjectFilterClause,
PartialFilters,
DataMaskStateWithId,
} from '@superset-ui/core';
import rison from 'rison';
export interface RisonFilter {
subject: string;
operator: string;
comparator: string | number | boolean | (string | number)[];
}
export interface IntelligentRisonInjectionResult {
updatedDataMask: DataMaskStateWithId;
unmatchedFilters: RisonFilter[];
}
/**
* Parse Rison filter syntax from URL parameter.
* Supports formats like: (country:USA,year:2024)
*/
export function parseRisonFilters(risonString: string): RisonFilter[] {
try {
const parsed = rison.decode(risonString);
const filters: RisonFilter[] = [];
if (!parsed || typeof parsed !== 'object') {
return filters;
}
const parsedObj = parsed as Record<string, unknown>;
// Handle OR operator: OR:!(condition1,condition2)
if (parsedObj.OR && Array.isArray(parsedObj.OR)) {
(parsedObj.OR as Record<string, unknown>[]).forEach(condition => {
if (typeof condition === 'object') {
Object.entries(condition).forEach(([key, value]) => {
filters.push(parseFilterCondition(key, value));
});
}
});
return filters;
}
// Handle NOT operator: NOT:(condition)
if (parsedObj.NOT && typeof parsedObj.NOT === 'object') {
Object.entries(parsedObj.NOT as Record<string, unknown>).forEach(
([key, value]) => {
const filter = parseFilterCondition(key, value);
if (filter.operator === '==') {
filter.operator = '!=';
} else if (filter.operator === 'IN') {
filter.operator = 'NOT IN';
}
filters.push(filter);
},
);
return filters;
}
// Handle regular filters
Object.entries(parsedObj).forEach(([key, value]) => {
if (key !== 'OR' && key !== 'NOT') {
filters.push(parseFilterCondition(key, value));
}
});
return filters;
} catch (error) {
console.warn('Failed to parse Rison filters:', error);
return [];
}
}
/**
* Parse individual filter condition
*/
function parseFilterCondition(key: string, value: unknown): RisonFilter {
// Handle comparison operators: (gt:100), (between:!(1,10))
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
const [operator, operatorValue] = Object.entries(
value as Record<string, unknown>,
)[0];
switch (operator) {
case 'gt':
return {
subject: key,
operator: '>',
comparator: operatorValue as string | number,
};
case 'gte':
return {
subject: key,
operator: '>=',
comparator: operatorValue as string | number,
};
case 'lt':
return {
subject: key,
operator: '<',
comparator: operatorValue as string | number,
};
case 'lte':
return {
subject: key,
operator: '<=',
comparator: operatorValue as string | number,
};
case 'between':
return {
subject: key,
operator: 'BETWEEN',
comparator: operatorValue as (string | number)[],
};
case 'like':
return {
subject: key,
operator: 'LIKE',
comparator: operatorValue as string,
};
default:
return {
subject: key,
operator: '==',
comparator: value as string | number,
};
}
}
// Handle IN operator: !(value1,value2)
if (Array.isArray(value)) {
return {
subject: key,
operator: 'IN',
comparator: value as (string | number)[],
};
}
// Handle simple equality
return {
subject: key,
operator: '==',
comparator: value as string | number | boolean,
};
}
/**
* Convert Rison filters to Superset adhoc filter format
*/
export function risonToAdhocFilters(
risonFilters: RisonFilter[],
): QueryObjectFilterClause[] {
return risonFilters.map(
filter =>
({
expressionType: 'SIMPLE' as const,
clause: 'WHERE' as const,
subject: filter.subject,
operator: filter.operator,
comparator: filter.comparator,
}) as unknown as QueryObjectFilterClause,
);
}
/**
* Prettify Rison filter URL by replacing encoded characters.
* Uses browser history API to update URL without page reload.
*/
export function prettifyRisonFilterUrl(): void {
try {
const currentUrl = window.location.href;
if (!currentUrl.includes('&f=') && !currentUrl.includes('?f=')) {
return;
}
const urlMatch = currentUrl.match(/([?&])f=([^&]*)/);
if (!urlMatch) {
return;
}
const separator = urlMatch[1];
let risonValue = urlMatch[2];
if (!risonValue.includes('%') && !risonValue.includes('+')) {
return;
}
let previousValue = '';
let decodeAttempts = 0;
while (risonValue !== previousValue && decodeAttempts < 5) {
previousValue = risonValue;
try {
if (risonValue.includes('%')) {
risonValue = decodeURIComponent(risonValue);
}
} catch {
break;
}
decodeAttempts += 1;
}
risonValue = risonValue.replace(/\+/g, ' ');
const matchIndex = urlMatch.index ?? 0;
const beforeRison = currentUrl.substring(0, matchIndex);
const afterRison = currentUrl.substring(matchIndex + urlMatch[0].length);
const prettifiedUrl = `${beforeRison}${separator}f=${risonValue}${afterRison}`;
if (prettifiedUrl !== currentUrl) {
window.history.replaceState(window.history.state, '', prettifiedUrl);
}
} catch (error) {
console.warn('Failed to prettify Rison URL:', error);
}
}
/**
* Get Rison filter parameter from URL
*/
export function getRisonFilterParam(): string | null {
const params = new URLSearchParams(window.location.search);
return params.get('f');
}
/**
* Convert an array of RisonFilter back to Rison string format
*/
export function risonFiltersToString(filters: RisonFilter[]): string {
if (filters.length === 0) {
return '';
}
const risonObject: Record<
string,
string | number | boolean | (string | number)[] | Record<string, unknown>
> = {};
filters.forEach(filter => {
if (filter.operator === 'IN' && Array.isArray(filter.comparator)) {
risonObject[filter.subject] = filter.comparator;
} else if (filter.operator === '==') {
risonObject[filter.subject] = filter.comparator;
} else {
const operatorMap: Record<string, string> = {
'>': 'gt',
'>=': 'gte',
'<': 'lt',
'<=': 'lte',
BETWEEN: 'between',
LIKE: 'like',
};
const risonOp = operatorMap[filter.operator] || filter.operator;
risonObject[filter.subject] = { [risonOp]: filter.comparator };
}
});
try {
return rison.encode(risonObject);
} catch (error) {
console.warn('Failed to encode Rison filters:', error);
return '';
}
}
/**
* Update the URL to remove successfully matched filters, keeping only unmatched ones
*/
export function updateUrlWithUnmatchedFilters(
unmatchedFilters: RisonFilter[],
): void {
try {
const currentUrl = new URL(window.location.href);
if (unmatchedFilters.length === 0) {
currentUrl.searchParams.delete('f');
} else {
const newRisonString = risonFiltersToString(unmatchedFilters);
if (newRisonString) {
currentUrl.searchParams.set('f', newRisonString);
} else {
currentUrl.searchParams.delete('f');
}
}
window.history.replaceState(
window.history.state,
'',
currentUrl.toString(),
);
} catch (error) {
console.warn('Failed to update URL with unmatched filters:', error);
}
}
/**
* Find a native filter that matches a Rison filter by column name.
* Uses case-insensitive, trimmed comparison to handle column names with spaces.
*/
function findMatchingNativeFilter(
risonFilter: RisonFilter,
nativeFilters: PartialFilters,
): string | null {
const normalizedSubject = risonFilter.subject.trim().toLowerCase();
for (const [filterId, nativeFilter] of Object.entries(nativeFilters)) {
if (!nativeFilter?.targets) continue;
const hasMatchingTarget = nativeFilter.targets.some(target => {
if (typeof target === 'object' && target && 'column' in target) {
return (
target.column?.name?.trim().toLowerCase() === normalizedSubject
);
}
return false;
});
if (hasMatchingTarget) {
return filterId;
}
}
return null;
}
/**
* Build extraFormData filters for a given rison filter and column name
*/
function buildExtraFormDataFilters(
risonFilter: RisonFilter,
columnName: string,
): { col: string; op: string; val: unknown }[] {
const { operator, comparator } = risonFilter;
if (operator === 'IN' || (operator === '==' && Array.isArray(comparator))) {
return [
{
col: columnName,
op: 'IN',
val: Array.isArray(comparator) ? comparator : [comparator],
},
];
}
if (operator === '==' && !Array.isArray(comparator)) {
return [{ col: columnName, op: 'IN', val: [comparator] }];
}
if (
operator === 'BETWEEN' &&
Array.isArray(comparator) &&
comparator.length === 2
) {
return [
{ col: columnName, op: '>=', val: comparator[0] },
{ col: columnName, op: '<=', val: comparator[1] },
];
}
return [{ col: columnName, op: operator, val: comparator }];
}
/**
* Convert a Rison filter value to the format expected by a native filter.
* Also returns extraFormData for auto-application.
*/
function convertRisonToNativeValue(
risonFilter: RisonFilter,
nativeFilter: { filterType?: string },
): unknown {
const { comparator, operator } = risonFilter;
const filterType = nativeFilter?.filterType;
switch (filterType) {
case 'filter_select':
if (operator === 'IN' || Array.isArray(comparator)) {
return Array.isArray(comparator) ? comparator : [comparator];
}
return [comparator];
case 'filter_range':
if (
operator === 'BETWEEN' &&
Array.isArray(comparator) &&
comparator.length === 2
) {
return { min: comparator[0], max: comparator[1] };
}
return comparator;
case 'filter_time_range':
case 'filter_timecolumn':
return comparator;
default:
return Array.isArray(comparator) ? comparator : [comparator];
}
}
/**
* Build a complete DataMask entry for a rison filter matched to a native filter.
* Sets both filterState.value AND extraFormData so the filter auto-applies.
*/
function buildDataMaskForFilter(
risonFilter: RisonFilter,
nativeFilter: { id: string; filterType?: string; targets?: { column?: { name?: string } }[] },
columnName: string,
) {
const convertedValue = convertRisonToNativeValue(risonFilter, nativeFilter);
return {
id: nativeFilter.id,
filterState: {
value: convertedValue,
},
extraFormData: {
filters: buildExtraFormDataFilters(risonFilter, columnName),
},
ownState: {},
};
}
/**
* Intelligently inject Rison filters into native filters where possible,
* falling back to brute-force injection for unmatched filters
*/
export function injectRisonFiltersIntelligently(
risonFilters: RisonFilter[],
nativeFilters: PartialFilters,
currentDataMask: DataMaskStateWithId,
): IntelligentRisonInjectionResult {
const updatedDataMask = { ...currentDataMask };
const unmatchedFilters: RisonFilter[] = [];
risonFilters.forEach(risonFilter => {
const matchingFilterId = findMatchingNativeFilter(
risonFilter,
nativeFilters,
);
if (matchingFilterId) {
const matchedFilter = nativeFilters[matchingFilterId];
if (matchedFilter) {
const columnName =
matchedFilter.targets?.[0]?.column?.name ?? risonFilter.subject;
const dataMaskEntry = buildDataMaskForFilter(
risonFilter,
matchedFilter as { id: string; filterType?: string; targets?: { column?: { name?: string } }[] },
columnName,
);
updatedDataMask[matchedFilter.id] = {
...updatedDataMask[matchedFilter.id],
...dataMaskEntry,
};
return;
}
}
unmatchedFilters.push(risonFilter);
});
return {
updatedDataMask,
unmatchedFilters,
};
}

View File

@@ -0,0 +1,226 @@
# 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.
"""
Parser for Rison URL filters that converts simplified filter syntax
to Superset's adhoc_filters format.
"""
from __future__ import annotations
import logging
from typing import Any, Optional, Union
import prison
from flask import request
logger = logging.getLogger(__name__)
class RisonFilterParser:
"""
Parse Rison filter syntax from URL parameter 'f' and convert to adhoc_filters.
Supports:
- Simple equality: f=(country:USA)
- Lists (IN): f=(country:!(USA,Canada))
- NOT operator: f=(NOT:(country:USA))
- OR operator: f=(OR:!(condition1,condition2))
- Comparison operators: f=(sales:(gt:100000))
- BETWEEN: f=(date:(between:!(2024-01-01,2024-12-31)))
- LIKE: f=(name:(like:'%smith%'))
"""
OPERATORS: dict[str, str] = {
"gt": ">",
"gte": ">=",
"lt": "<",
"lte": "<=",
"between": "BETWEEN",
"like": "LIKE",
"ilike": "ILIKE",
"ne": "!=",
"eq": "==",
}
def parse(self, filter_string: Optional[str] = None) -> list[dict[str, Any]]:
"""
Parse Rison filter string and convert to adhoc_filters format.
Args:
filter_string: Rison-encoded filter string, or None to get from request
Returns:
List of adhoc_filter dictionaries
"""
if filter_string is None:
filter_string = request.args.get("f")
if not filter_string:
return []
try:
filters_obj = prison.loads(filter_string)
return self._convert_to_adhoc_filters(filters_obj)
except Exception:
logger.warning(
"Failed to parse Rison filters: %s", filter_string, exc_info=True
)
return []
def _convert_to_adhoc_filters(
self, filters_obj: Union[dict[str, Any], list[Any], Any]
) -> list[dict[str, Any]]:
if not isinstance(filters_obj, dict):
return []
adhoc_filters: list[dict[str, Any]] = []
for key, value in filters_obj.items():
if key == "OR":
adhoc_filters.extend(self._handle_or_operator(value))
elif key == "NOT":
adhoc_filters.extend(self._handle_not_operator(value))
else:
filter_dict = self._create_filter(key, value)
if filter_dict:
adhoc_filters.append(filter_dict)
return adhoc_filters
def _create_filter(
self, column: str, value: Any, negate: bool = False
) -> Optional[dict[str, Any]]:
filter_dict: dict[str, Any] = {
"expressionType": "SIMPLE",
"clause": "WHERE",
"subject": column,
}
if isinstance(value, list):
filter_dict["operator"] = "NOT IN" if negate else "IN"
filter_dict["comparator"] = value
elif isinstance(value, dict):
operator_info = self._parse_operator_dict(value)
if operator_info:
operator, comparator = operator_info
if negate and operator == "==":
operator = "!="
elif negate and operator == "IN":
operator = "NOT IN"
filter_dict["operator"] = operator
filter_dict["comparator"] = comparator
else:
return None
else:
filter_dict["operator"] = "!=" if negate else "=="
filter_dict["comparator"] = value
return filter_dict
def _parse_operator_dict(
self, op_dict: dict[str, Any]
) -> Optional[tuple[str, Any]]:
if not op_dict:
return None
for op_key, op_value in op_dict.items():
if op_key in self.OPERATORS:
operator = self.OPERATORS[op_key]
if (
operator == "BETWEEN"
and isinstance(op_value, list)
and len(op_value) == 2
):
return operator, op_value
return operator, op_value
if op_key == "in":
return "IN", op_value if isinstance(op_value, list) else [op_value]
if op_key == "nin":
return "NOT IN", op_value if isinstance(op_value, list) else [op_value]
return None
def _handle_or_operator(self, or_value: Any) -> list[dict[str, Any]]:
if not isinstance(or_value, list):
return []
sql_parts: list[str] = []
for item in or_value:
if isinstance(item, dict):
for col, val in item.items():
if col not in ("OR", "NOT"):
sql_part = self._build_sql_condition(col, val)
if sql_part:
sql_parts.append(sql_part)
if sql_parts:
return [
{
"expressionType": "SQL",
"clause": "WHERE",
"sqlExpression": f"({' OR '.join(sql_parts)})",
}
]
return []
def _build_sql_condition(self, column: str, value: Any) -> Optional[str]:
if isinstance(value, list):
values_str = ", ".join(
[f"'{v}'" if isinstance(v, str) else str(v) for v in value]
)
return f"{column} IN ({values_str})"
if isinstance(value, dict):
operator_info = self._parse_operator_dict(value)
if operator_info:
op, comp = operator_info
if op == "BETWEEN" and isinstance(comp, list):
return f"{column} BETWEEN '{comp[0]}' AND '{comp[1]}'"
if op == "LIKE":
return f"{column} LIKE '{comp}'"
comp_str = f"'{comp}'" if isinstance(comp, str) else str(comp)
return f"{column} {op} {comp_str}"
val_str = f"'{value}'" if isinstance(value, str) else str(value)
return f"{column} = {val_str}"
def _handle_not_operator(self, not_value: Any) -> list[dict[str, Any]]:
if isinstance(not_value, dict):
filters: list[dict[str, Any]] = []
for col, val in not_value.items():
if col not in ("OR", "NOT"):
filter_dict = self._create_filter(col, val, negate=True)
if filter_dict:
filters.append(filter_dict)
return filters
return []
def merge_rison_filters(form_data: dict[str, Any]) -> None:
"""
Merge Rison filters from 'f' parameter into form_data.
Modifies form_data in place.
"""
parser = RisonFilterParser()
if rison_filters := parser.parse():
existing_filters = form_data.get("adhoc_filters", [])
form_data["adhoc_filters"] = existing_filters + rison_filters
logger.info("Added %d filters from Rison parameter", len(rison_filters))

View File

@@ -0,0 +1,133 @@
# 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.
"""Unit tests for Rison filter parser."""
from superset.utils.rison_filters import RisonFilterParser
def test_simple_equality():
parser = RisonFilterParser()
result = parser.parse("(country:USA)")
assert len(result) == 1
assert result[0]["expressionType"] == "SIMPLE"
assert result[0]["clause"] == "WHERE"
assert result[0]["subject"] == "country"
assert result[0]["operator"] == "=="
assert result[0]["comparator"] == "USA"
def test_multiple_filters_and():
parser = RisonFilterParser()
result = parser.parse("(country:USA,year:2024)")
assert len(result) == 2
assert result[0]["subject"] == "country"
assert result[0]["comparator"] == "USA"
assert result[1]["subject"] == "year"
assert result[1]["comparator"] == 2024
def test_list_in_operator():
parser = RisonFilterParser()
result = parser.parse("(country:!(USA,Canada))")
assert len(result) == 1
assert result[0]["subject"] == "country"
assert result[0]["operator"] == "IN"
assert result[0]["comparator"] == ["USA", "Canada"]
def test_not_operator():
parser = RisonFilterParser()
result = parser.parse("(NOT:(country:USA))")
assert len(result) == 1
assert result[0]["subject"] == "country"
assert result[0]["operator"] == "!="
assert result[0]["comparator"] == "USA"
def test_not_in_operator():
parser = RisonFilterParser()
result = parser.parse("(NOT:(country:!(USA,Canada)))")
assert len(result) == 1
assert result[0]["subject"] == "country"
assert result[0]["operator"] == "NOT IN"
assert result[0]["comparator"] == ["USA", "Canada"]
def test_or_operator():
parser = RisonFilterParser()
result = parser.parse("(OR:!((status:active),(priority:high)))")
assert len(result) == 1
assert result[0]["expressionType"] == "SQL"
assert result[0]["clause"] == "WHERE"
assert "status = 'active' OR priority = 'high'" in result[0]["sqlExpression"]
def test_comparison_operators():
parser = RisonFilterParser()
result = parser.parse("(sales:(gt:100000))")
assert result[0]["operator"] == ">"
assert result[0]["comparator"] == 100000
result = parser.parse("(age:(gte:18))")
assert result[0]["operator"] == ">="
assert result[0]["comparator"] == 18
result = parser.parse("(temp:(lt:32))")
assert result[0]["operator"] == "<"
assert result[0]["comparator"] == 32
result = parser.parse("(price:(lte:1000))")
assert result[0]["operator"] == "<="
assert result[0]["comparator"] == 1000
def test_between_operator():
parser = RisonFilterParser()
result = parser.parse("(date:(between:!('2024-01-01','2024-12-31')))")
assert len(result) == 1
assert result[0]["operator"] == "BETWEEN"
assert result[0]["comparator"] == ["2024-01-01", "2024-12-31"]
def test_like_operator():
parser = RisonFilterParser()
result = parser.parse("(name:(like:'%smith%'))")
assert len(result) == 1
assert result[0]["operator"] == "LIKE"
assert result[0]["comparator"] == "%smith%"
def test_empty_filter():
parser = RisonFilterParser()
assert parser.parse("") == []
assert parser.parse("()") == []
def test_invalid_rison():
parser = RisonFilterParser()
assert parser.parse("invalid rison") == []
assert parser.parse("(unclosed") == []