mirror of
https://github.com/apache/superset.git
synced 2026-06-01 13:49:21 +00:00
feat(dashboard): add Rison-encoded URL filter support (#39795)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -17,20 +17,39 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { FC, memo, useMemo } from 'react';
|
||||
import { FC, memo, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { DataMaskStateWithId } from '@superset-ui/core';
|
||||
import {
|
||||
DataMaskStateWithId,
|
||||
QueryObjectFilterClause,
|
||||
} 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 { useDispatch, useSelector } from 'react-redux';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
import { removeDataMask, updateDataMask } from 'src/dataMask/actions';
|
||||
import {
|
||||
getRisonFilterParam,
|
||||
parseRisonFilters,
|
||||
RISON_UNMATCHED_DATAMASK_ID,
|
||||
risonFiltersToExtraFormDataFilters,
|
||||
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,
|
||||
getUrlFilterIdentity,
|
||||
UrlFilterIndicator,
|
||||
} from './UrlFilters/urlFilterUtils';
|
||||
import UrlFilterTag from './UrlFilters/UrlFilterTag';
|
||||
|
||||
const HorizontalBar = styled.div`
|
||||
${({ theme }) => `
|
||||
@@ -65,6 +84,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,
|
||||
@@ -79,6 +120,9 @@ const HorizontalFilterBar: FC<HorizontalBarProps> = ({
|
||||
const dataMask = useSelector<RootState, DataMaskStateWithId>(
|
||||
state => state.dataMask,
|
||||
);
|
||||
const dispatch = useDispatch();
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
const chartIds = useChartIds();
|
||||
const chartLayoutItems = useChartLayoutItems();
|
||||
const verboseMaps = useChartsVerboseMaps();
|
||||
@@ -94,9 +138,71 @@ const HorizontalFilterBar: FC<HorizontalBarProps> = ({
|
||||
[chartIds, chartLayoutItems, dataMask, verboseMaps],
|
||||
);
|
||||
|
||||
const [activeUrlFilters, setActiveUrlFilters] = useState<
|
||||
UrlFilterIndicator[]
|
||||
>(() => getUrlFilterIndicators());
|
||||
|
||||
// Re-read chips whenever the URL changes (back/forward navigation, or a
|
||||
// programmatic history.replace).
|
||||
useEffect(() => {
|
||||
setActiveUrlFilters(getUrlFilterIndicators());
|
||||
}, [location.search]);
|
||||
|
||||
const handleRemoveUrlFilter = useCallback(
|
||||
(filterToRemove: UrlFilterIndicator) => {
|
||||
const risonParam = getRisonFilterParam();
|
||||
if (!risonParam) return;
|
||||
|
||||
const removeId = getUrlFilterIdentity(filterToRemove.filter);
|
||||
const currentFilters = parseRisonFilters(risonParam);
|
||||
const remaining = currentFilters.filter(
|
||||
f => getUrlFilterIdentity(f) !== removeId,
|
||||
);
|
||||
updateUrlWithUnmatchedFilters(remaining, history);
|
||||
setActiveUrlFilters(prev =>
|
||||
prev.filter(f => getUrlFilterIdentity(f.filter) !== removeId),
|
||||
);
|
||||
|
||||
if (remaining.length === 0) {
|
||||
dispatch(removeDataMask(RISON_UNMATCHED_DATAMASK_ID));
|
||||
} else {
|
||||
const extraFormDataFilters: QueryObjectFilterClause[] =
|
||||
risonFiltersToExtraFormDataFilters(remaining);
|
||||
dispatch(
|
||||
updateDataMask(RISON_UNMATCHED_DATAMASK_ID, {
|
||||
extraFormData: { filters: extraFormDataFilters },
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
[dispatch, history],
|
||||
);
|
||||
|
||||
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={getUrlFilterIdentity(filter.filter)}
|
||||
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 +219,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}
|
||||
</>
|
||||
|
||||
@@ -36,6 +36,7 @@ const renderWrapper = (overrideProps?: Record<string, any>) =>
|
||||
waitFor(() =>
|
||||
render(<HorizontalBar {...defaultProps} {...overrideProps} />, {
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
initialState: {
|
||||
dashboardState: {
|
||||
sliceIds: [],
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* 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 './urlFilterUtils';
|
||||
|
||||
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)}
|
||||
>
|
||||
<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;
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/**
|
||||
* Validates PR review items #2 and #7:
|
||||
* - chip removal must dispatch a dataMask update so charts re-query
|
||||
* (otherwise the URL changes but the synthetic Rison filter keeps
|
||||
* influencing chart results until full reload).
|
||||
* - the chip list must react to URL changes (back/forward navigation or
|
||||
* a programmatic history.replace), not snapshot the URL at mount.
|
||||
*/
|
||||
import { createMemoryHistory } from 'history';
|
||||
import { Router } from 'react-router-dom';
|
||||
import { act, render, screen, userEvent } from 'spec/helpers/testing-library';
|
||||
import { REMOVE_DATA_MASK, UPDATE_DATA_MASK } from 'src/dataMask/actions';
|
||||
import { RISON_UNMATCHED_DATAMASK_ID } from 'src/dashboard/util/risonFilters';
|
||||
import UrlFiltersVertical from './Vertical';
|
||||
|
||||
const mockDispatch = jest.fn();
|
||||
jest.mock('react-redux', () => ({
|
||||
...jest.requireActual('react-redux'),
|
||||
useDispatch: () => mockDispatch,
|
||||
}));
|
||||
|
||||
const seedUrl = (search: string) => {
|
||||
// jsdom doesn't navigate, so set both window.location (read by
|
||||
// getRisonFilterParam) and react-router's in-memory history.
|
||||
window.history.replaceState({}, '', `/superset/dashboard/1/${search}`);
|
||||
};
|
||||
|
||||
const renderAt = (search: string) => {
|
||||
seedUrl(search);
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: [`/superset/dashboard/1/${search}`],
|
||||
});
|
||||
const utils = render(
|
||||
<Router history={history}>
|
||||
<UrlFiltersVertical />
|
||||
</Router>,
|
||||
{ useRedux: true },
|
||||
);
|
||||
return { ...utils, history };
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockDispatch.mockReset();
|
||||
window.history.replaceState({}, '', '/');
|
||||
});
|
||||
|
||||
test('renders chips parsed from the f= URL param', () => {
|
||||
renderAt('?f=(region:EMEA,channel:web)');
|
||||
|
||||
expect(screen.getByText('URL Filters')).toBeInTheDocument();
|
||||
expect(screen.getByText('region')).toBeInTheDocument();
|
||||
expect(screen.getByText('EMEA')).toBeInTheDocument();
|
||||
expect(screen.getByText('channel')).toBeInTheDocument();
|
||||
expect(screen.getByText('web')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders nothing when there is no f= param', () => {
|
||||
renderAt('');
|
||||
|
||||
expect(screen.queryByText('URL Filters')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('removing a chip dispatches updateDataMask with the remaining filters', async () => {
|
||||
renderAt('?f=(region:EMEA,channel:web)');
|
||||
|
||||
const closeButtons = screen.getAllByRole('img', { name: /close/i });
|
||||
expect(closeButtons).toHaveLength(2);
|
||||
await userEvent.click(closeButtons[0]);
|
||||
|
||||
// The remaining filter must still apply to charts, so updateDataMask
|
||||
// (not removeDataMask) is dispatched with one filter left.
|
||||
const updateCalls = mockDispatch.mock.calls
|
||||
.map(([action]) => action)
|
||||
.filter(action => action?.type === UPDATE_DATA_MASK);
|
||||
expect(updateCalls).toHaveLength(1);
|
||||
expect(updateCalls[0].filterId).toBe(RISON_UNMATCHED_DATAMASK_ID);
|
||||
expect(updateCalls[0].dataMask.extraFormData.filters).toHaveLength(1);
|
||||
expect(updateCalls[0].dataMask.extraFormData.filters[0]).toMatchObject({
|
||||
col: 'channel',
|
||||
val: ['web'],
|
||||
});
|
||||
|
||||
// The chip we clicked is gone from the UI.
|
||||
expect(screen.queryByText('region')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('removing the last chip dispatches removeDataMask, not an empty update', async () => {
|
||||
renderAt('?f=(region:EMEA)');
|
||||
|
||||
await userEvent.click(screen.getByRole('img', { name: /close/i }));
|
||||
|
||||
const removeCalls = mockDispatch.mock.calls
|
||||
.map(([action]) => action)
|
||||
.filter(action => action?.type === REMOVE_DATA_MASK);
|
||||
expect(removeCalls).toHaveLength(1);
|
||||
expect(removeCalls[0].filterId).toBe(RISON_UNMATCHED_DATAMASK_ID);
|
||||
|
||||
// No stray updateDataMask with empty filters.
|
||||
const updateCalls = mockDispatch.mock.calls
|
||||
.map(([action]) => action)
|
||||
.filter(action => action?.type === UPDATE_DATA_MASK);
|
||||
expect(updateCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('chip list re-renders when the URL changes (popstate/programmatic nav)', () => {
|
||||
const { history } = renderAt('?f=(region:EMEA)');
|
||||
|
||||
expect(screen.getByText('region')).toBeInTheDocument();
|
||||
expect(screen.queryByText('priority')).not.toBeInTheDocument();
|
||||
|
||||
// Simulate a programmatic URL change (e.g. via history.push or a back/
|
||||
// forward nav). The component must re-read the URL filters.
|
||||
act(() => {
|
||||
seedUrl('?f=(priority:high)');
|
||||
history.replace('/superset/dashboard/1/?f=(priority:high)');
|
||||
});
|
||||
|
||||
expect(screen.getByText('priority')).toBeInTheDocument();
|
||||
expect(screen.getByText('high')).toBeInTheDocument();
|
||||
expect(screen.queryByText('region')).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* 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 { useCallback, useEffect, useState } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
import { QueryObjectFilterClause } from '@superset-ui/core';
|
||||
import { removeDataMask, updateDataMask } from 'src/dataMask/actions';
|
||||
import {
|
||||
getRisonFilterParam,
|
||||
parseRisonFilters,
|
||||
RISON_UNMATCHED_DATAMASK_ID,
|
||||
risonFiltersToExtraFormDataFilters,
|
||||
updateUrlWithUnmatchedFilters,
|
||||
} from 'src/dashboard/util/risonFilters';
|
||||
import {
|
||||
getUrlFilterIdentity,
|
||||
getUrlFilterIndicators,
|
||||
UrlFilterIndicator,
|
||||
} from './urlFilterUtils';
|
||||
import UrlFiltersVerticalCollapse from './VerticalCollapse';
|
||||
|
||||
const UrlFiltersVertical = () => {
|
||||
const dispatch = useDispatch();
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
const [urlFilters, setUrlFilters] = useState<UrlFilterIndicator[]>(() =>
|
||||
getUrlFilterIndicators(),
|
||||
);
|
||||
|
||||
// Re-read chips whenever the URL changes (back/forward navigation, or a
|
||||
// programmatic history.replace).
|
||||
useEffect(() => {
|
||||
setUrlFilters(getUrlFilterIndicators());
|
||||
}, [location.search]);
|
||||
|
||||
const handleRemoveFilter = useCallback(
|
||||
(filterToRemove: UrlFilterIndicator) => {
|
||||
const risonParam = getRisonFilterParam();
|
||||
if (!risonParam) return;
|
||||
|
||||
const removeId = getUrlFilterIdentity(filterToRemove.filter);
|
||||
const currentFilters = parseRisonFilters(risonParam);
|
||||
const remaining = currentFilters.filter(
|
||||
f => getUrlFilterIdentity(f) !== removeId,
|
||||
);
|
||||
|
||||
updateUrlWithUnmatchedFilters(remaining, history);
|
||||
setUrlFilters(prev =>
|
||||
prev.filter(f => getUrlFilterIdentity(f.filter) !== removeId),
|
||||
);
|
||||
|
||||
if (remaining.length === 0) {
|
||||
dispatch(removeDataMask(RISON_UNMATCHED_DATAMASK_ID));
|
||||
} else {
|
||||
const extraFormDataFilters: QueryObjectFilterClause[] =
|
||||
risonFiltersToExtraFormDataFilters(remaining);
|
||||
dispatch(
|
||||
updateDataMask(RISON_UNMATCHED_DATAMASK_ID, {
|
||||
extraFormData: { filters: extraFormDataFilters },
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
[dispatch, history],
|
||||
);
|
||||
|
||||
if (!urlFilters.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<UrlFiltersVerticalCollapse
|
||||
urlFilters={urlFilters}
|
||||
onRemoveFilter={handleRemoveFilter}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default UrlFiltersVertical;
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* 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 { useCallback, useMemo, useState } 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 UrlFilterTag from './UrlFilterTag';
|
||||
import { UrlFilterIndicator, getUrlFilterIdentity } from './urlFilterUtils';
|
||||
|
||||
const sectionContainerStyle = (theme: SupersetTheme) => css`
|
||||
margin-bottom: ${theme.sizeUnit * 3}px;
|
||||
padding: 0 ${theme.sizeUnit * 4}px;
|
||||
`;
|
||||
|
||||
const sectionHeaderStyle = (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 = (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 = (theme: SupersetTheme) => css`
|
||||
padding: ${theme.sizeUnit * 2}px 0;
|
||||
`;
|
||||
|
||||
const dividerStyle = (theme: SupersetTheme) => css`
|
||||
height: 1px;
|
||||
background: ${theme.colorSplit};
|
||||
margin: ${theme.sizeUnit * 2}px 0;
|
||||
`;
|
||||
|
||||
const iconStyle = (open: boolean, theme: SupersetTheme) => css`
|
||||
transform: ${open ? 'rotate(0deg)' : 'rotate(180deg)'};
|
||||
transition: transform 0.2s ease;
|
||||
color: ${theme.colorTextSecondary};
|
||||
`;
|
||||
|
||||
const UrlFiltersVerticalCollapse = (props: {
|
||||
urlFilters: UrlFilterIndicator[];
|
||||
onRemoveFilter: (filter: UrlFilterIndicator) => void;
|
||||
}) => {
|
||||
const { urlFilters, onRemoveFilter } = props;
|
||||
const theme = useTheme();
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
|
||||
const toggleSection = useCallback(() => {
|
||||
setIsOpen(prev => !prev);
|
||||
}, []);
|
||||
|
||||
const filterIndicators = useMemo(
|
||||
() =>
|
||||
urlFilters.map(filter => (
|
||||
<UrlFilterTag
|
||||
key={getUrlFilterIdentity(filter.filter)}
|
||||
filter={filter}
|
||||
orientation={FilterBarOrientation.Vertical}
|
||||
onRemove={onRemoveFilter}
|
||||
/>
|
||||
)),
|
||||
[urlFilters, onRemoveFilter],
|
||||
);
|
||||
|
||||
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;
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a stable identity string for a URL filter so duplicates on the same
|
||||
* column (e.g., multiple conditions on `country`) get distinct React keys and
|
||||
* can be removed individually.
|
||||
*/
|
||||
export function getUrlFilterIdentity(filter: RisonFilter): string {
|
||||
return `${filter.subject}|${filter.operator}|${JSON.stringify(
|
||||
filter.comparator,
|
||||
)}`;
|
||||
}
|
||||
|
||||
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,
|
||||
}));
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
|
||||
@@ -107,8 +107,14 @@ const publishDataMask = debounce(
|
||||
const previousParams = new URLSearchParams(search);
|
||||
const newParams = new URLSearchParams();
|
||||
let dataMaskKey: string | null;
|
||||
// Capture the raw, still-URL-encoded `f=` payload from the query string
|
||||
// directly. URLSearchParams decodes values (and turns `+` into space), which
|
||||
// would corrupt the Rison payload if we re-inserted it without re-encoding.
|
||||
const rawRisonMatch = search.match(/[?&]f=([^&]*)/);
|
||||
const rawRisonFilterValue = rawRisonMatch ? rawRisonMatch[1] : null;
|
||||
|
||||
previousParams.forEach((value, key) => {
|
||||
if (!EXCLUDED_URL_PARAMS.includes(key)) {
|
||||
if (!EXCLUDED_URL_PARAMS.includes(key) && key !== 'f') {
|
||||
newParams.append(key, value);
|
||||
}
|
||||
});
|
||||
@@ -148,9 +154,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 (rawRisonFilterValue) {
|
||||
const separator = searchString ? '&' : '';
|
||||
searchString = `${searchString}${separator}f=${rawRisonFilterValue}`;
|
||||
}
|
||||
|
||||
history.replace({
|
||||
pathname: replacementPathname,
|
||||
search: newParams.toString(),
|
||||
search: searchString,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -64,6 +64,18 @@ import SyncDashboardState, {
|
||||
getDashboardContextLocalStorage,
|
||||
} from '../components/SyncDashboardState';
|
||||
import { AutoRefreshProvider } from '../contexts/AutoRefreshContext';
|
||||
import { Filter, PartialFilters } from '@superset-ui/core';
|
||||
import {
|
||||
parseRisonFilters,
|
||||
risonFiltersToExtraFormDataFilters,
|
||||
getRisonFilterParam,
|
||||
prettifyRisonFilterUrl,
|
||||
injectRisonFiltersIntelligently,
|
||||
updateUrlWithUnmatchedFilters,
|
||||
RISON_UNMATCHED_DATAMASK_ID,
|
||||
} from '../util/risonFilters';
|
||||
|
||||
type NativeFilterConfigEntry = Partial<Filter> & { id: string };
|
||||
|
||||
export const DashboardPageIdContext = createContext('');
|
||||
|
||||
@@ -195,6 +207,63 @@ 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 NativeFilterConfigEntry[];
|
||||
const nativeFilters: PartialFilters = {};
|
||||
filterConfigArray.forEach(filter => {
|
||||
nativeFilters[filter.id] = filter;
|
||||
});
|
||||
const injectionResult = injectRisonFiltersIntelligently(
|
||||
risonFilters,
|
||||
nativeFilters,
|
||||
dataMask,
|
||||
);
|
||||
|
||||
dataMask = injectionResult.updatedDataMask;
|
||||
|
||||
// Unmatched filters apply via a synthetic dataMask entry: because no
|
||||
// entry in `nativeFilters` claims this id, `getAllActiveFilters`
|
||||
// falls through to `allSliceIds` and the filters scope to every chart.
|
||||
if (injectionResult.unmatchedFilters.length > 0) {
|
||||
const extraFormDataFilters = risonFiltersToExtraFormDataFilters(
|
||||
injectionResult.unmatchedFilters,
|
||||
);
|
||||
|
||||
dataMask = {
|
||||
...dataMask,
|
||||
[RISON_UNMATCHED_DATAMASK_ID]: {
|
||||
id: RISON_UNMATCHED_DATAMASK_ID,
|
||||
extraFormData: { filters: extraFormDataFilters },
|
||||
filterState: {},
|
||||
ownState: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Rewrite the URL to drop matched filters in a single step, keeping
|
||||
// only unmatched ones (and prettifying their encoding). Going
|
||||
// through react-router's history keeps `history.location.search` in
|
||||
// sync so `publishDataMask` doesn't re-emit the original `f=`.
|
||||
const matchedCount =
|
||||
risonFilters.length - injectionResult.unmatchedFilters.length;
|
||||
if (matchedCount > 0) {
|
||||
updateUrlWithUnmatchedFilters(
|
||||
injectionResult.unmatchedFilters,
|
||||
history,
|
||||
);
|
||||
}
|
||||
if (injectionResult.unmatchedFilters.length > 0) {
|
||||
prettifyRisonFilterUrl();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (readyToRender) {
|
||||
if (!isDashboardHydrated.current) {
|
||||
isDashboardHydrated.current = true;
|
||||
|
||||
110
superset-frontend/src/dashboard/util/UrlFilters.scope.test.ts
Normal file
110
superset-frontend/src/dashboard/util/UrlFilters.scope.test.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/**
|
||||
* Pinning test for the unmatched-Rison-filter wiring (PR review item #1).
|
||||
*
|
||||
* The reviewer flagged that unmatched URL filters were being written to
|
||||
* dataMask under a key (`__rison_filters__`) that no downstream code reads —
|
||||
* so unmatched filters silently dropped instead of applying to charts.
|
||||
*
|
||||
* The fix uses a synthetic dataMask id (`RISON_UNMATCHED_DATAMASK_ID`) whose
|
||||
* `extraFormData.filters` falls through to `getAllActiveFilters`'s
|
||||
* `allSliceIds` scope fallback, hitting every chart on the dashboard. This
|
||||
* test pins down that contract so a future refactor of `getAllActiveFilters`
|
||||
* can't silently break the wiring again.
|
||||
*/
|
||||
import { DataMaskStateWithId } from '@superset-ui/core';
|
||||
import { getAllActiveFilters } from './activeAllDashboardFilters';
|
||||
import {
|
||||
RISON_UNMATCHED_DATAMASK_ID,
|
||||
risonFiltersToExtraFormDataFilters,
|
||||
} from './risonFilters';
|
||||
|
||||
test('synthetic Rison-unmatched dataMask entry scopes to every chart', () => {
|
||||
const allSliceIds = [101, 202, 303];
|
||||
const dataMask: DataMaskStateWithId = {
|
||||
[RISON_UNMATCHED_DATAMASK_ID]: {
|
||||
id: RISON_UNMATCHED_DATAMASK_ID,
|
||||
extraFormData: {
|
||||
filters: risonFiltersToExtraFormDataFilters([
|
||||
{ subject: 'region', operator: '==', comparator: 'EMEA' },
|
||||
]),
|
||||
},
|
||||
filterState: {},
|
||||
ownState: {},
|
||||
},
|
||||
};
|
||||
|
||||
const activeFilters = getAllActiveFilters({
|
||||
chartConfiguration: {},
|
||||
nativeFilters: {}, // no native filter claims the synthetic id
|
||||
dataMask,
|
||||
allSliceIds,
|
||||
});
|
||||
|
||||
const entry = activeFilters[RISON_UNMATCHED_DATAMASK_ID];
|
||||
expect(entry).toBeDefined();
|
||||
// Scope MUST equal allSliceIds — that's the whole reason this works.
|
||||
expect(entry.scope).toEqual(allSliceIds);
|
||||
// And the filters must be in the {col, op, val} shape getExtraFormData merges.
|
||||
expect(entry.values).toEqual({
|
||||
filters: [{ col: 'region', op: 'IN', val: ['EMEA'] }],
|
||||
});
|
||||
});
|
||||
|
||||
test('synthetic entry coexists with real native filters without overlap', () => {
|
||||
const allSliceIds = [1, 2, 3];
|
||||
const dataMask: DataMaskStateWithId = {
|
||||
NATIVE_FILTER_country: {
|
||||
id: 'NATIVE_FILTER_country',
|
||||
extraFormData: {
|
||||
filters: [{ col: 'country', op: 'IN', val: ['USA'] }],
|
||||
},
|
||||
filterState: { value: ['USA'] },
|
||||
ownState: {},
|
||||
},
|
||||
[RISON_UNMATCHED_DATAMASK_ID]: {
|
||||
id: RISON_UNMATCHED_DATAMASK_ID,
|
||||
extraFormData: {
|
||||
filters: [{ col: 'region', op: 'IN', val: ['EMEA'] }],
|
||||
},
|
||||
filterState: {},
|
||||
ownState: {},
|
||||
},
|
||||
};
|
||||
|
||||
const activeFilters = getAllActiveFilters({
|
||||
chartConfiguration: {},
|
||||
nativeFilters: {
|
||||
NATIVE_FILTER_country: {
|
||||
id: 'NATIVE_FILTER_country',
|
||||
chartsInScope: [1], // native filter is narrow
|
||||
targets: [{ column: { name: 'country' } }],
|
||||
filterType: 'filter_select',
|
||||
},
|
||||
},
|
||||
dataMask,
|
||||
allSliceIds,
|
||||
});
|
||||
|
||||
// Native filter keeps its narrow scope.
|
||||
expect(activeFilters.NATIVE_FILTER_country.scope).toEqual([1]);
|
||||
// Synthetic filter scopes to all slices.
|
||||
expect(activeFilters[RISON_UNMATCHED_DATAMASK_ID].scope).toEqual(allSliceIds);
|
||||
});
|
||||
411
superset-frontend/src/dashboard/util/risonFilters.test.ts
Normal file
411
superset-frontend/src/dashboard/util/risonFilters.test.ts
Normal file
@@ -0,0 +1,411 @@
|
||||
/**
|
||||
* 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,
|
||||
risonFiltersToExtraFormDataFilters,
|
||||
risonFiltersToString,
|
||||
risonToAdhocFilters,
|
||||
updateUrlWithUnmatchedFilters,
|
||||
} 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('');
|
||||
});
|
||||
|
||||
test('risonFiltersToExtraFormDataFilters expands BETWEEN into two clauses', () => {
|
||||
const filters: RisonFilter[] = [
|
||||
{ subject: 'country', operator: '==', comparator: 'USA' },
|
||||
{ subject: 'year', operator: 'BETWEEN', comparator: [2020, 2024] },
|
||||
];
|
||||
|
||||
expect(risonFiltersToExtraFormDataFilters(filters)).toEqual([
|
||||
{ col: 'country', op: 'IN', val: ['USA'] },
|
||||
{ col: 'year', op: '>=', val: 2020 },
|
||||
{ col: 'year', op: '<=', val: 2024 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('updateUrlWithUnmatchedFilters goes through history when supplied', () => {
|
||||
const replace = jest.fn();
|
||||
const history = { replace };
|
||||
|
||||
// Seed the URL so the function has something to read.
|
||||
const originalLocation = window.location.href;
|
||||
window.history.replaceState({}, '', '/superset/dashboard/1/?f=(country:USA)');
|
||||
|
||||
updateUrlWithUnmatchedFilters(
|
||||
[{ subject: 'region', operator: '==', comparator: 'EMEA' }],
|
||||
history,
|
||||
);
|
||||
|
||||
expect(replace).toHaveBeenCalledTimes(1);
|
||||
const call = replace.mock.calls[0][0];
|
||||
expect(call.pathname).toBe('/superset/dashboard/1/');
|
||||
expect(call.search).toContain('f=');
|
||||
expect(call.search).toContain('region');
|
||||
|
||||
// Restore.
|
||||
window.history.replaceState({}, '', originalLocation);
|
||||
});
|
||||
|
||||
test('updateUrlWithUnmatchedFilters drops f= when no unmatched remain', () => {
|
||||
const replace = jest.fn();
|
||||
const originalLocation = window.location.href;
|
||||
window.history.replaceState({}, '', '/superset/dashboard/1/?f=(country:USA)');
|
||||
|
||||
updateUrlWithUnmatchedFilters([], { replace });
|
||||
|
||||
expect(replace).toHaveBeenCalledTimes(1);
|
||||
expect(replace.mock.calls[0][0].search).toBe('');
|
||||
|
||||
window.history.replaceState({}, '', originalLocation);
|
||||
});
|
||||
|
||||
test('updateUrlWithUnmatchedFilters cleanup is observable by history readers', () => {
|
||||
// Validates PR review item #3: the URL cleanup must go through a path
|
||||
// that downstream history-readers (e.g. publishDataMask) observe. The
|
||||
// raw window.history.replaceState fallback alone left react-router's
|
||||
// history.location.search stale, causing publishDataMask to re-append
|
||||
// the original f= on the next interaction.
|
||||
//
|
||||
// Stand in for react-router's history with a fake whose `.location`
|
||||
// updates synchronously when .replace is called — same contract as
|
||||
// react-router-dom's history.replace.
|
||||
const fakeHistory = {
|
||||
location: {
|
||||
pathname: '/superset/dashboard/1/',
|
||||
search: '?f=(country:USA)',
|
||||
},
|
||||
replace(next: { pathname: string; search: string }) {
|
||||
this.location = next;
|
||||
},
|
||||
};
|
||||
const originalLocation = window.location.href;
|
||||
window.history.replaceState({}, '', '/superset/dashboard/1/?f=(country:USA)');
|
||||
|
||||
updateUrlWithUnmatchedFilters(
|
||||
[{ subject: 'sales', operator: '>', comparator: 1000 }],
|
||||
fakeHistory,
|
||||
);
|
||||
|
||||
// After cleanup, a reader of history.location.search (the same path
|
||||
// publishDataMask uses) must NOT see the original matched filter.
|
||||
expect(fakeHistory.location.search).not.toContain('country');
|
||||
expect(fakeHistory.location.search).toContain('sales');
|
||||
|
||||
window.history.replaceState({}, '', originalLocation);
|
||||
});
|
||||
569
superset-frontend/src/dashboard/util/risonFilters.ts
Normal file
569
superset-frontend/src/dashboard/util/risonFilters.ts
Normal file
@@ -0,0 +1,569 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
/**
|
||||
* Synthetic dataMask key for URL Rison filters that don't match any native
|
||||
* filter on the dashboard. Because no entry in `nativeFilters` claims this id,
|
||||
* `getAllActiveFilters` falls through to `allSliceIds`, so the attached
|
||||
* `extraFormData.filters` apply to every chart on the dashboard.
|
||||
*/
|
||||
export const RISON_UNMATCHED_DATAMASK_ID = '__rison_unmatched__';
|
||||
|
||||
export interface RisonFilter {
|
||||
subject: string;
|
||||
operator: string;
|
||||
comparator: string | number | boolean | (string | number)[];
|
||||
}
|
||||
|
||||
export interface IntelligentRisonInjectionResult {
|
||||
updatedDataMask: DataMaskStateWithId;
|
||||
unmatchedFilters: RisonFilter[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 unknown 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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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). Falls through so regular keys at the
|
||||
// same level are still picked up below (supports mixed payloads like
|
||||
// `(country:USA,NOT:(status:archived))`).
|
||||
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);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// 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 [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 '';
|
||||
}
|
||||
|
||||
type RisonValue =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| (string | number)[]
|
||||
| Record<string, unknown>;
|
||||
const risonObject: Record<string, RisonValue> = {};
|
||||
const notObject: Record<string, RisonValue> = {};
|
||||
|
||||
const encodePositive = (
|
||||
target: Record<string, RisonValue>,
|
||||
filter: RisonFilter,
|
||||
op: string,
|
||||
) => {
|
||||
if (op === 'IN' && Array.isArray(filter.comparator)) {
|
||||
target[filter.subject] = filter.comparator;
|
||||
} else if (op === '==') {
|
||||
target[filter.subject] = filter.comparator;
|
||||
} else {
|
||||
const operatorMap: Record<string, string> = {
|
||||
'>': 'gt',
|
||||
'>=': 'gte',
|
||||
'<': 'lt',
|
||||
'<=': 'lte',
|
||||
BETWEEN: 'between',
|
||||
LIKE: 'like',
|
||||
ILIKE: 'ilike',
|
||||
};
|
||||
const risonOp = operatorMap[op] || op;
|
||||
target[filter.subject] = { [risonOp]: filter.comparator };
|
||||
}
|
||||
};
|
||||
|
||||
filters.forEach(filter => {
|
||||
if (filter.operator === '!=') {
|
||||
// Re-emit as NOT:(col:value) so parseRisonFilters can read it back.
|
||||
encodePositive(notObject, filter, '==');
|
||||
} else if (filter.operator === 'NOT IN') {
|
||||
encodePositive(notObject, filter, 'IN');
|
||||
} else {
|
||||
encodePositive(risonObject, filter, filter.operator);
|
||||
}
|
||||
});
|
||||
|
||||
if (Object.keys(notObject).length > 0) {
|
||||
risonObject.NOT = notObject;
|
||||
}
|
||||
|
||||
try {
|
||||
return rison.encode(risonObject);
|
||||
} catch (error) {
|
||||
console.warn('Failed to encode Rison filters:', error);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
interface ReplaceHistory {
|
||||
replace(location: { pathname: string; search: string }): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the URL to remove successfully matched filters, keeping only unmatched ones.
|
||||
* When a react-router history is supplied, the update goes through it so that
|
||||
* components reading from `history.location` (e.g. `publishDataMask` in the
|
||||
* filter bar) see the new search string. Otherwise falls back to a raw
|
||||
* `window.history.replaceState`.
|
||||
*/
|
||||
export function updateUrlWithUnmatchedFilters(
|
||||
unmatchedFilters: RisonFilter[],
|
||||
history?: ReplaceHistory,
|
||||
): 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');
|
||||
}
|
||||
}
|
||||
|
||||
// Always keep window.history in sync so callers that read
|
||||
// `window.location.search` (e.g. `getRisonFilterParam`) see the update.
|
||||
// With a real `BrowserRouter`, `history.replace` would do this too — but
|
||||
// under a `createMemoryHistory` (used in tests, or in some embedded
|
||||
// contexts) it does not, and we'd leak the stale URL into the next
|
||||
// `getRisonFilterParam()` call.
|
||||
window.history.replaceState(
|
||||
window.history.state,
|
||||
'',
|
||||
currentUrl.toString(),
|
||||
);
|
||||
if (history) {
|
||||
history.replace({
|
||||
pathname: currentUrl.pathname,
|
||||
search: currentUrl.search,
|
||||
});
|
||||
}
|
||||
} 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,
|
||||
): QueryObjectFilterClause[] {
|
||||
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,
|
||||
} as QueryObjectFilterClause,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a list of Rison filters into the `{col, op, val}` clauses expected
|
||||
* by `dataMask[id].extraFormData.filters`. Each filter uses its own subject
|
||||
* as the column name.
|
||||
*/
|
||||
export function risonFiltersToExtraFormDataFilters(
|
||||
filters: RisonFilter[],
|
||||
): QueryObjectFilterClause[] {
|
||||
return filters.flatMap(filter =>
|
||||
buildExtraFormDataFilters(filter, filter.subject),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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];
|
||||
const filterId = matchedFilter?.id ?? matchingFilterId;
|
||||
if (matchedFilter && filterId) {
|
||||
const columnName =
|
||||
matchedFilter.targets?.[0]?.column?.name ?? risonFilter.subject;
|
||||
|
||||
const dataMaskEntry = buildDataMaskForFilter(
|
||||
risonFilter,
|
||||
{ ...matchedFilter, id: filterId } as {
|
||||
id: string;
|
||||
filterType?: string;
|
||||
targets?: { column?: { name?: string } }[];
|
||||
},
|
||||
columnName,
|
||||
);
|
||||
|
||||
updatedDataMask[filterId] = {
|
||||
...updatedDataMask[filterId],
|
||||
...dataMaskEntry,
|
||||
};
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
unmatchedFilters.push(risonFilter);
|
||||
});
|
||||
|
||||
return {
|
||||
updatedDataMask,
|
||||
unmatchedFilters,
|
||||
};
|
||||
}
|
||||
274
superset/utils/rison_filters.py
Normal file
274
superset/utils/rison_filters.py
Normal file
@@ -0,0 +1,274 @@
|
||||
# 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
|
||||
import re
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
import prison
|
||||
from flask import has_request_context, request
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# SQL identifiers permitted in URL-supplied filter columns. Restricted to
|
||||
# alphanumeric/underscore (optionally schema-qualified) so OR-path SQL
|
||||
# generation never interpolates an attacker-controlled identifier verbatim.
|
||||
_SAFE_IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?$")
|
||||
|
||||
|
||||
def _safe_identifier(name: Any) -> Optional[str]:
|
||||
if isinstance(name, str) and _SAFE_IDENTIFIER_RE.match(name):
|
||||
return name
|
||||
return None
|
||||
|
||||
|
||||
def _quote_sql_literal(value: Any) -> Optional[str]:
|
||||
"""Quote a scalar value safely for SQL string interpolation.
|
||||
|
||||
- Strings: single-quoted with `'` escaped as `''` (SQL standard).
|
||||
- Numeric / bool: rendered bare.
|
||||
- Anything else (None, dict, list, etc.): rejected.
|
||||
"""
|
||||
if isinstance(value, bool):
|
||||
return "TRUE" if value else "FALSE"
|
||||
if isinstance(value, (int, float)):
|
||||
return str(value)
|
||||
if isinstance(value, str):
|
||||
return "'" + value.replace("'", "''") + "'"
|
||||
return None
|
||||
|
||||
|
||||
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:
|
||||
# Callers outside a Flask request context (CLI, async tasks, tests)
|
||||
# would otherwise hit a RuntimeError on `request.args` access.
|
||||
if not has_request_context():
|
||||
return []
|
||||
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]:
|
||||
# URL-supplied columns flow directly into a raw SQL expression in the
|
||||
# OR path, so the identifier must match a strict whitelist or we drop
|
||||
# the whole condition. Likewise, string literals get `'` escaped to
|
||||
# `''` and unrecognised value types are rejected outright.
|
||||
safe_column = _safe_identifier(column)
|
||||
if safe_column is None:
|
||||
logger.warning("Rejecting URL filter with unsafe column: %r", column)
|
||||
return None
|
||||
|
||||
if isinstance(value, list):
|
||||
quoted = [_quote_sql_literal(v) for v in value]
|
||||
if any(q is None for q in quoted):
|
||||
return None
|
||||
return f"{safe_column} IN ({', '.join(quoted)})" # type: ignore[arg-type]
|
||||
|
||||
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) and len(comp) == 2:
|
||||
lo = _quote_sql_literal(comp[0])
|
||||
hi = _quote_sql_literal(comp[1])
|
||||
if lo is None or hi is None:
|
||||
return None
|
||||
return f"{safe_column} BETWEEN {lo} AND {hi}"
|
||||
comp_str = _quote_sql_literal(comp)
|
||||
if comp_str is None:
|
||||
return None
|
||||
return f"{safe_column} {op} {comp_str}"
|
||||
return None
|
||||
|
||||
val_str = _quote_sql_literal(value)
|
||||
if val_str is None:
|
||||
return None
|
||||
return f"{safe_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))
|
||||
183
tests/unit_tests/utils/test_rison_filters.py
Normal file
183
tests/unit_tests/utils/test_rison_filters.py
Normal file
@@ -0,0 +1,183 @@
|
||||
# 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") == []
|
||||
|
||||
|
||||
def test_or_branch_escapes_string_literals():
|
||||
"""OR-branch SQL must escape apostrophes; otherwise a value like
|
||||
`O'Brien'); DROP TABLE x; --` could close out a literal and inject.
|
||||
|
||||
Bypasses the Rison parser (whose quoting rules complicate expressing
|
||||
apostrophes in test inputs) and exercises `_handle_or_operator` with
|
||||
a hand-built decoded payload — which is exactly what `_convert_to_adhoc_filters`
|
||||
feeds it after a successful prison.loads.
|
||||
"""
|
||||
parser = RisonFilterParser()
|
||||
result = parser._handle_or_operator(
|
||||
[{"name": "O'Brien"}, {"role": "admin"}],
|
||||
)
|
||||
|
||||
assert len(result) == 1
|
||||
sql = result[0]["sqlExpression"]
|
||||
# Single quote inside the literal is doubled per SQL standard.
|
||||
assert "'O''Brien'" in sql
|
||||
assert "'admin'" in sql
|
||||
|
||||
|
||||
def test_or_branch_rejects_unsafe_identifiers():
|
||||
"""Columns that don't match a strict identifier whitelist drop the
|
||||
condition rather than getting interpolated into SQL."""
|
||||
parser = RisonFilterParser()
|
||||
result = parser._handle_or_operator(
|
||||
[{"col; DROP TABLE x": 1}, {"role": "admin"}],
|
||||
)
|
||||
|
||||
# The unsafe column is dropped, the safe one remains.
|
||||
assert len(result) == 1
|
||||
sql = result[0]["sqlExpression"]
|
||||
assert "DROP" not in sql
|
||||
assert "role = 'admin'" in sql
|
||||
|
||||
|
||||
def test_or_branch_all_conditions_unsafe_returns_nothing():
|
||||
parser = RisonFilterParser()
|
||||
result = parser._handle_or_operator(
|
||||
[{"col;1": 1}, {"col;2": 2}],
|
||||
)
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_or_branch_between_quotes_both_bounds():
|
||||
parser = RisonFilterParser()
|
||||
result = parser.parse("(OR:!((date:(between:!('2024-01-01','2024-12-31')))))")
|
||||
sql = result[0]["sqlExpression"]
|
||||
assert "date BETWEEN '2024-01-01' AND '2024-12-31'" in sql
|
||||
Reference in New Issue
Block a user