diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/Horizontal.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/Horizontal.tsx index 96e05dddba5..5bc33cc5a8e 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/Horizontal.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/Horizontal.tsx @@ -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 = ({ actions, dataMaskSelected, @@ -94,9 +127,47 @@ const HorizontalFilterBar: FC = ({ [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 ( + + + + {t('URL Filters')} + + {activeUrlFilters.map(filter => ( + + ))} + + ); + }, [activeUrlFilters, handleRemoveUrlFilter]); + const hasFilters = filterValues.length > 0 || selectedCrossFilters.length > 0 || + activeUrlFilters.length > 0 || chartCustomizationValues.length > 0; return ( @@ -113,16 +184,19 @@ const HorizontalFilterBar: FC = ({ )} {hasFilters && ( - + <> + {urlFiltersComponent} + + )} {actions} diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/UrlFilters/UrlFilterTag.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/UrlFilters/UrlFilterTag.tsx new file mode 100644 index 00000000000..25417f9201a --- /dev/null +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/UrlFilters/UrlFilterTag.tsx @@ -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(); + const [valueRef, valueIsTruncated] = useCSSTextTruncation(); + + return ( + onRemove(filter)} + editable + > + + {filter.subject} + + + {filter.value} + + + ); +}; + +export default UrlFilterTag; diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/UrlFilters/Vertical.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/UrlFilters/Vertical.tsx new file mode 100644 index 00000000000..71f57cdf838 --- /dev/null +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/UrlFilters/Vertical.tsx @@ -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 ; +}; + +export default UrlFiltersVertical; diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/UrlFilters/VerticalCollapse.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/UrlFilters/VerticalCollapse.tsx new file mode 100644 index 00000000000..5b79412c8dc --- /dev/null +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/UrlFilters/VerticalCollapse.tsx @@ -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(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 => ( + + )), + [urlFilters, handleRemoveFilter], + ); + + if (!urlFilters.length) { + return null; + } + + return ( +
+
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + toggleSection(); + } + }} + role="button" + tabIndex={0} + > +

+ + {t('URL Filters')} +

+ +
+ {isOpen &&
{filterIndicators}
} + {isOpen &&
} +
+ ); +}; + +export default UrlFiltersVerticalCollapse; diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/UrlFilters/selectors.ts b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/UrlFilters/selectors.ts new file mode 100644 index 00000000000..47843579562 --- /dev/null +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/UrlFilters/selectors.ts @@ -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, + })); +} diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/Vertical.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/Vertical.tsx index 864db364a38..5f1e097c1c4 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/Vertical.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/Vertical.tsx @@ -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 = ({ ) : (
<> + {filterControls} diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/index.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/index.tsx index 702f4678add..dd8ed70ec70 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/index.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/index.tsx @@ -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, }); } }, diff --git a/superset-frontend/src/dashboard/containers/DashboardPage.tsx b/superset-frontend/src/dashboard/containers/DashboardPage.tsx index f6d755adf6d..c1f0c925cdd 100644 --- a/superset-frontend/src/dashboard/containers/DashboardPage.tsx +++ b/superset-frontend/src/dashboard/containers/DashboardPage.tsx @@ -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 = ({ 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 & { 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; diff --git a/superset-frontend/src/dashboard/util/risonFilters.test.ts b/superset-frontend/src/dashboard/util/risonFilters.test.ts new file mode 100644 index 00000000000..aef3c695a59 --- /dev/null +++ b/superset-frontend/src/dashboard/util/risonFilters.test.ts @@ -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(''); +}); diff --git a/superset-frontend/src/dashboard/util/risonFilters.ts b/superset-frontend/src/dashboard/util/risonFilters.ts new file mode 100644 index 00000000000..2214dd80e6a --- /dev/null +++ b/superset-frontend/src/dashboard/util/risonFilters.ts @@ -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; + + // Handle OR operator: OR:!(condition1,condition2) + if (parsedObj.OR && Array.isArray(parsedObj.OR)) { + (parsedObj.OR as Record[]).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).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, + )[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 + > = {}; + + 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 = { + '>': '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, + }; +} diff --git a/superset/utils/rison_filters.py b/superset/utils/rison_filters.py new file mode 100644 index 00000000000..4c9711df1e0 --- /dev/null +++ b/superset/utils/rison_filters.py @@ -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)) diff --git a/tests/unit_tests/utils/test_rison_filters.py b/tests/unit_tests/utils/test_rison_filters.py new file mode 100644 index 00000000000..ab30bd6ff60 --- /dev/null +++ b/tests/unit_tests/utils/test_rison_filters.py @@ -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") == []