mirror of
https://github.com/apache/superset.git
synced 2026-04-19 08:04:53 +00:00
feat(ag-grid): Server Side Filtering for Column Level Filters (#35683)
This commit is contained in:
@@ -19,9 +19,10 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { useRef, useState } from 'react';
|
||||
import { useRef, useState, useEffect } from 'react';
|
||||
import { t } from '@apache-superset/core';
|
||||
import { ArrowDownOutlined, ArrowUpOutlined } from '@ant-design/icons';
|
||||
import { Column } from '@superset-ui/core/components/ThemedAgGridReact';
|
||||
import FilterIcon from './Filter';
|
||||
import KebabMenu from './KebabMenu';
|
||||
import {
|
||||
@@ -29,6 +30,8 @@ import {
|
||||
CustomHeaderParams,
|
||||
SortState,
|
||||
UserProvidedColDef,
|
||||
FilterInputPosition,
|
||||
AGGridFilterInstance,
|
||||
} from '../../types';
|
||||
import CustomPopover from './CustomPopover';
|
||||
import {
|
||||
@@ -39,6 +42,13 @@ import {
|
||||
MenuContainer,
|
||||
SortIconWrapper,
|
||||
} from '../../styles';
|
||||
import { GridApi } from 'ag-grid-community';
|
||||
import {
|
||||
FILTER_POPOVER_OPEN_DELAY,
|
||||
FILTER_INPUT_POSITIONS,
|
||||
FILTER_CONDITION_BODY_INDEX,
|
||||
FILTER_INPUT_SELECTOR,
|
||||
} from '../../consts';
|
||||
|
||||
const getSortIcon = (sortState: SortState[], colId: string | null) => {
|
||||
if (!sortState?.length || !colId) return null;
|
||||
@@ -53,6 +63,43 @@ const getSortIcon = (sortState: SortState[], colId: string | null) => {
|
||||
return null;
|
||||
};
|
||||
|
||||
// Auto-opens filter popover and focuses the correct input after server-side filtering
|
||||
const autoOpenFilterAndFocus = async (
|
||||
column: Column,
|
||||
api: GridApi,
|
||||
filterRef: React.RefObject<HTMLDivElement>,
|
||||
setFilterVisible: (visible: boolean) => void,
|
||||
lastFilteredInputPosition?: FilterInputPosition,
|
||||
) => {
|
||||
setFilterVisible(true);
|
||||
|
||||
const filterInstance = (await api.getColumnFilterInstance(
|
||||
column,
|
||||
)) as AGGridFilterInstance | null;
|
||||
const filterEl = filterInstance?.eGui;
|
||||
|
||||
if (!filterEl || !filterRef.current) return;
|
||||
|
||||
filterRef.current.innerHTML = '';
|
||||
filterRef.current.appendChild(filterEl);
|
||||
|
||||
if (filterInstance?.eConditionBodies) {
|
||||
const conditionBodies = filterInstance.eConditionBodies;
|
||||
const targetIndex =
|
||||
lastFilteredInputPosition === FILTER_INPUT_POSITIONS.SECOND
|
||||
? FILTER_CONDITION_BODY_INDEX.SECOND
|
||||
: FILTER_CONDITION_BODY_INDEX.FIRST;
|
||||
const targetBody = conditionBodies[targetIndex];
|
||||
|
||||
if (targetBody) {
|
||||
const input = targetBody.querySelector(
|
||||
FILTER_INPUT_SELECTOR,
|
||||
) as HTMLInputElement | null;
|
||||
input?.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const CustomHeader: React.FC<CustomHeaderParams> = ({
|
||||
displayName,
|
||||
enableSorting,
|
||||
@@ -61,7 +108,12 @@ const CustomHeader: React.FC<CustomHeaderParams> = ({
|
||||
column,
|
||||
api,
|
||||
}) => {
|
||||
const { initialSortState, onColumnHeaderClicked } = context;
|
||||
const {
|
||||
initialSortState,
|
||||
onColumnHeaderClicked,
|
||||
lastFilteredColumn,
|
||||
lastFilteredInputPosition,
|
||||
} = context;
|
||||
const colId = column?.getColId();
|
||||
const colDef = column?.getColDef() as CustomColDef;
|
||||
const userColDef = column.getUserProvidedColDef() as UserProvidedColDef;
|
||||
@@ -77,7 +129,6 @@ const CustomHeader: React.FC<CustomHeaderParams> = ({
|
||||
const isTimeComparison = !isMain && userColDef?.timeComparisonKey;
|
||||
const sortKey = isMain ? colId.replace('Main', '').trim() : colId;
|
||||
|
||||
// Sorting logic
|
||||
const clearSort = () => {
|
||||
onColumnHeaderClicked({ column: { colId: sortKey, sort: null } });
|
||||
setSort(null, false);
|
||||
@@ -106,7 +157,9 @@ const CustomHeader: React.FC<CustomHeaderParams> = ({
|
||||
e.stopPropagation();
|
||||
setFilterVisible(!isFilterVisible);
|
||||
|
||||
const filterInstance = await api.getColumnFilterInstance<any>(column);
|
||||
const filterInstance = (await api.getColumnFilterInstance(
|
||||
column,
|
||||
)) as AGGridFilterInstance | null;
|
||||
const filterEl = filterInstance?.eGui;
|
||||
if (filterEl && filterRef.current) {
|
||||
filterRef.current.innerHTML = '';
|
||||
@@ -114,6 +167,25 @@ const CustomHeader: React.FC<CustomHeaderParams> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// Re-open filter popover after server refresh (delay allows AG Grid to finish rendering)
|
||||
useEffect(() => {
|
||||
if (lastFilteredColumn === colId && !isFilterVisible) {
|
||||
const timeoutId = setTimeout(
|
||||
() =>
|
||||
autoOpenFilterAndFocus(
|
||||
column,
|
||||
api,
|
||||
filterRef,
|
||||
setFilterVisible,
|
||||
lastFilteredInputPosition,
|
||||
),
|
||||
FILTER_POPOVER_OPEN_DELAY,
|
||||
);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
return undefined;
|
||||
}, [lastFilteredColumn, colId, lastFilteredInputPosition]);
|
||||
|
||||
const handleMenuClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setMenuVisible(!isMenuVisible);
|
||||
|
||||
@@ -55,7 +55,9 @@ import Pagination from './components/Pagination';
|
||||
import SearchSelectDropdown from './components/SearchSelectDropdown';
|
||||
import { SearchOption, SortByItem } from '../types';
|
||||
import getInitialSortState, { shouldSort } from '../utils/getInitialSortState';
|
||||
import getInitialFilterModel from '../utils/getInitialFilterModel';
|
||||
import { PAGE_SIZE_OPTIONS } from '../consts';
|
||||
import { getCompleteFilterState } from '../utils/filterStateManager';
|
||||
|
||||
export interface AgGridState extends Partial<GridState> {
|
||||
timestamp?: number;
|
||||
@@ -100,6 +102,8 @@ export interface AgGridTableProps {
|
||||
showTotals: boolean;
|
||||
width: number;
|
||||
onColumnStateChange?: (state: AgGridChartStateWithMetadata) => void;
|
||||
onFilterChanged?: (filterModel: Record<string, any>) => void;
|
||||
metricColumns?: string[];
|
||||
gridRef?: RefObject<AgGridReact>;
|
||||
chartState?: AgGridChartState;
|
||||
}
|
||||
@@ -137,6 +141,8 @@ const AgGridDataTable: FunctionComponent<AgGridTableProps> = memo(
|
||||
showTotals,
|
||||
width,
|
||||
onColumnStateChange,
|
||||
onFilterChanged,
|
||||
metricColumns = [],
|
||||
chartState,
|
||||
}) => {
|
||||
const gridRef = useRef<AgGridReact>(null);
|
||||
@@ -144,14 +150,27 @@ const AgGridDataTable: FunctionComponent<AgGridTableProps> = memo(
|
||||
const rowData = useMemo(() => data, [data]);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const lastCapturedStateRef = useRef<string | null>(null);
|
||||
const filterOperationVersionRef = useRef(0);
|
||||
|
||||
const searchId = `search-${id}`;
|
||||
|
||||
const initialFilterModel = getInitialFilterModel(
|
||||
chartState,
|
||||
serverPaginationData,
|
||||
serverPagination,
|
||||
);
|
||||
|
||||
const gridInitialState: GridState = {
|
||||
...(serverPagination && {
|
||||
sort: {
|
||||
sortModel: getInitialSortState(serverPaginationData?.sortBy || []),
|
||||
},
|
||||
}),
|
||||
...(initialFilterModel && {
|
||||
filter: {
|
||||
filterModel: initialFilterModel,
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const defaultColDef = useMemo<ColDef>(
|
||||
@@ -332,6 +351,56 @@ const AgGridDataTable: FunctionComponent<AgGridTableProps> = memo(
|
||||
[onColumnStateChange],
|
||||
);
|
||||
|
||||
const handleFilterChanged = useCallback(async () => {
|
||||
filterOperationVersionRef.current += 1;
|
||||
const currentVersion = filterOperationVersionRef.current;
|
||||
|
||||
const completeFilterState = await getCompleteFilterState(
|
||||
gridRef,
|
||||
metricColumns,
|
||||
);
|
||||
|
||||
// Skip stale operations from rapid filter changes
|
||||
if (currentVersion !== filterOperationVersionRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reject invalid filter states (e.g., text filter on numeric column)
|
||||
if (completeFilterState.originalFilterModel) {
|
||||
const filterModel = completeFilterState.originalFilterModel;
|
||||
const hasInvalidFilterType = Object.entries(filterModel).some(
|
||||
([colId, filter]: [string, any]) => {
|
||||
if (
|
||||
filter?.filterType === 'text' &&
|
||||
metricColumns?.includes(colId)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
);
|
||||
|
||||
if (hasInvalidFilterType) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!isEqual(
|
||||
serverPaginationData?.agGridFilterModel,
|
||||
completeFilterState.originalFilterModel,
|
||||
)
|
||||
) {
|
||||
if (onFilterChanged) {
|
||||
onFilterChanged(completeFilterState);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
onFilterChanged,
|
||||
metricColumns,
|
||||
serverPaginationData?.agGridFilterModel,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
hasServerPageLengthChanged &&
|
||||
@@ -356,19 +425,14 @@ const AgGridDataTable: FunctionComponent<AgGridTableProps> = memo(
|
||||
// This will make columns fill the grid width
|
||||
params.api.sizeColumnsToFit();
|
||||
|
||||
// Restore saved AG Grid state from permalink if available
|
||||
if (chartState && params.api) {
|
||||
// Restore saved column state from permalink if available
|
||||
// Note: filterModel is now handled via gridInitialState for better performance
|
||||
if (chartState?.columnState && params.api) {
|
||||
try {
|
||||
if (chartState.columnState) {
|
||||
params.api.applyColumnState?.({
|
||||
state: chartState.columnState as ColumnState[],
|
||||
applyOrder: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (chartState.filterModel) {
|
||||
params.api.setFilterModel?.(chartState.filterModel);
|
||||
}
|
||||
params.api.applyColumnState?.({
|
||||
state: chartState.columnState as ColumnState[],
|
||||
applyOrder: true,
|
||||
});
|
||||
} catch {
|
||||
// Silently fail if state restoration fails
|
||||
}
|
||||
@@ -429,6 +493,7 @@ const AgGridDataTable: FunctionComponent<AgGridTableProps> = memo(
|
||||
rowSelection="multiple"
|
||||
animateRows
|
||||
onCellClicked={handleCrossFilter}
|
||||
onFilterChanged={handleFilterChanged}
|
||||
onStateUpdated={handleGridStateChange}
|
||||
initialState={gridInitialState}
|
||||
maintainColumnOrder
|
||||
@@ -520,6 +585,9 @@ const AgGridDataTable: FunctionComponent<AgGridTableProps> = memo(
|
||||
serverPaginationData?.sortBy || [],
|
||||
),
|
||||
isActiveFilterValue,
|
||||
lastFilteredColumn: serverPaginationData?.lastFilteredColumn,
|
||||
lastFilteredInputPosition:
|
||||
serverPaginationData?.lastFilteredInputPosition,
|
||||
}}
|
||||
/>
|
||||
{serverPagination && (
|
||||
|
||||
@@ -42,6 +42,7 @@ import TimeComparisonVisibility from './AgGridTable/components/TimeComparisonVis
|
||||
import { useColDefs } from './utils/useColDefs';
|
||||
import { getCrossFilterDataMask } from './utils/getCrossFilterDataMask';
|
||||
import { StyledChartContainer } from './styles';
|
||||
import type { FilterState } from './utils/filterStateManager';
|
||||
|
||||
const getGridHeight = (height: number, includeSearch: boolean | undefined) => {
|
||||
let calculatedGridHeight = height;
|
||||
@@ -88,6 +89,15 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
|
||||
const [searchOptions, setSearchOptions] = useState<SearchOption[]>([]);
|
||||
|
||||
// Extract metric column names for SQL conversion
|
||||
const metricColumns = useMemo(
|
||||
() =>
|
||||
columns
|
||||
.filter(col => col.isMetric || col.isPercentMetric)
|
||||
.map(col => col.key),
|
||||
[columns],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const options = columns
|
||||
.filter(col => col?.dataType === GenericDataType.String)
|
||||
@@ -101,6 +111,29 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
}
|
||||
}, [columns]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!serverPagination || !serverPaginationData || !rowCount) return;
|
||||
|
||||
const currentPage = serverPaginationData.currentPage ?? 0;
|
||||
const currentPageSize = serverPaginationData.pageSize ?? serverPageLength;
|
||||
const totalPages = Math.ceil(rowCount / currentPageSize);
|
||||
|
||||
if (currentPage >= totalPages && totalPages > 0) {
|
||||
const validPage = Math.max(0, totalPages - 1);
|
||||
const modifiedOwnState = {
|
||||
...serverPaginationData,
|
||||
currentPage: validPage,
|
||||
};
|
||||
updateTableOwnState(setDataMask, modifiedOwnState);
|
||||
}
|
||||
}, [
|
||||
rowCount,
|
||||
serverPagination,
|
||||
serverPaginationData,
|
||||
serverPageLength,
|
||||
setDataMask,
|
||||
]);
|
||||
|
||||
const comparisonColumns = [
|
||||
{ key: 'all', label: t('Display all') },
|
||||
{ key: '#', label: '#' },
|
||||
@@ -121,6 +154,52 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
[onChartStateChange],
|
||||
);
|
||||
|
||||
const handleFilterChanged = useCallback(
|
||||
(completeFilterState: FilterState) => {
|
||||
if (!serverPagination) return;
|
||||
// Sync chartState immediately with the new filter model to prevent stale state
|
||||
// This ensures chartState and ownState are in sync
|
||||
if (onChartStateChange && chartState) {
|
||||
const filterModel =
|
||||
completeFilterState.originalFilterModel &&
|
||||
Object.keys(completeFilterState.originalFilterModel).length > 0
|
||||
? completeFilterState.originalFilterModel
|
||||
: undefined;
|
||||
const updatedChartState = {
|
||||
...chartState,
|
||||
filterModel,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
onChartStateChange(updatedChartState);
|
||||
}
|
||||
|
||||
// Prepare modified own state for server pagination
|
||||
const modifiedOwnState = {
|
||||
...serverPaginationData,
|
||||
agGridFilterModel:
|
||||
completeFilterState.originalFilterModel &&
|
||||
Object.keys(completeFilterState.originalFilterModel).length > 0
|
||||
? completeFilterState.originalFilterModel
|
||||
: undefined,
|
||||
agGridSimpleFilters: completeFilterState.simpleFilters,
|
||||
agGridComplexWhere: completeFilterState.complexWhere,
|
||||
agGridHavingClause: completeFilterState.havingClause,
|
||||
lastFilteredColumn: completeFilterState.lastFilteredColumn,
|
||||
lastFilteredInputPosition: completeFilterState.inputPosition,
|
||||
currentPage: 0, // Reset to first page when filtering
|
||||
};
|
||||
|
||||
updateTableOwnState(setDataMask, modifiedOwnState);
|
||||
},
|
||||
[
|
||||
setDataMask,
|
||||
serverPagination,
|
||||
serverPaginationData,
|
||||
onChartStateChange,
|
||||
chartState,
|
||||
],
|
||||
);
|
||||
|
||||
const filteredColumns = useMemo(() => {
|
||||
if (!isUsingTimeComparison) {
|
||||
return columns;
|
||||
@@ -206,6 +285,8 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
...serverPaginationData,
|
||||
currentPage: pageNumber,
|
||||
pageSize,
|
||||
lastFilteredColumn: undefined,
|
||||
lastFilteredInputPosition: undefined,
|
||||
};
|
||||
updateTableOwnState(setDataMask, modifiedOwnState);
|
||||
},
|
||||
@@ -218,6 +299,8 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
...serverPaginationData,
|
||||
currentPage: 0,
|
||||
pageSize,
|
||||
lastFilteredColumn: undefined,
|
||||
lastFilteredInputPosition: undefined,
|
||||
};
|
||||
updateTableOwnState(setDataMask, modifiedOwnState);
|
||||
},
|
||||
@@ -230,6 +313,8 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
...serverPaginationData,
|
||||
searchColumn: searchCol,
|
||||
searchText: '',
|
||||
lastFilteredColumn: undefined,
|
||||
lastFilteredInputPosition: undefined,
|
||||
};
|
||||
updateTableOwnState(setDataMask, modifiedOwnState);
|
||||
}
|
||||
@@ -243,6 +328,8 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
serverPaginationData?.searchColumn || searchOptions[0]?.value,
|
||||
searchText,
|
||||
currentPage: 0, // Reset to first page when searching
|
||||
lastFilteredColumn: undefined,
|
||||
lastFilteredInputPosition: undefined,
|
||||
};
|
||||
updateTableOwnState(setDataMask, modifiedOwnState);
|
||||
},
|
||||
@@ -255,6 +342,8 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
const modifiedOwnState = {
|
||||
...serverPaginationData,
|
||||
sortBy,
|
||||
lastFilteredColumn: undefined,
|
||||
lastFilteredInputPosition: undefined,
|
||||
};
|
||||
updateTableOwnState(setDataMask, modifiedOwnState);
|
||||
},
|
||||
@@ -288,6 +377,8 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
onSearchColChange={handleChangeSearchCol}
|
||||
onSearchChange={handleSearch}
|
||||
onSortChange={handleSortByChange}
|
||||
onFilterChanged={handleFilterChanged}
|
||||
metricColumns={metricColumns}
|
||||
id={slice_id}
|
||||
handleCrossFilter={toggleFilter}
|
||||
percentMetrics={percentMetrics}
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
ensureIsArray,
|
||||
getColumnLabel,
|
||||
getMetricLabel,
|
||||
isDefined,
|
||||
isPhysicalColumn,
|
||||
QueryFormColumn,
|
||||
QueryFormMetric,
|
||||
@@ -253,13 +254,23 @@ const buildQuery: BuildQuery<TableChartFormData> = (
|
||||
};
|
||||
|
||||
sortByFromOwnState = sortSource
|
||||
.map((sortItem: any) => {
|
||||
const colId = sortItem?.colId || sortItem?.key;
|
||||
const sortKey = mapColIdToIdentifier(colId);
|
||||
if (!sortKey) return null;
|
||||
const isDesc = sortItem?.sort === 'desc' || sortItem?.desc;
|
||||
return [sortKey, !isDesc] as QueryFormOrderBy;
|
||||
})
|
||||
.map(
|
||||
(sortItem: {
|
||||
colId?: string | number;
|
||||
key?: string | number;
|
||||
sort?: string;
|
||||
desc?: boolean;
|
||||
}) => {
|
||||
const colId = isDefined(sortItem?.colId)
|
||||
? sortItem.colId
|
||||
: sortItem?.key;
|
||||
if (!isDefined(colId)) return null;
|
||||
const sortKey = mapColIdToIdentifier(String(colId));
|
||||
if (!sortKey) return null;
|
||||
const isDesc = sortItem?.sort === 'desc' || sortItem?.desc;
|
||||
return [sortKey, !isDesc] as QueryFormOrderBy;
|
||||
},
|
||||
)
|
||||
.filter((item): item is QueryFormOrderBy => item !== null);
|
||||
|
||||
// Add secondary sort for stable ordering (matches AG Grid's stable sort behavior)
|
||||
@@ -361,6 +372,8 @@ const buildQuery: BuildQuery<TableChartFormData> = (
|
||||
...options?.ownState,
|
||||
currentPage: 0,
|
||||
pageSize: queryObject.row_limit ?? 0,
|
||||
lastFilteredColumn: undefined,
|
||||
lastFilteredInputPosition: undefined,
|
||||
};
|
||||
updateTableOwnState(options?.hooks?.setDataMask, modifiedOwnState);
|
||||
}
|
||||
@@ -370,21 +383,6 @@ const buildQuery: BuildQuery<TableChartFormData> = (
|
||||
});
|
||||
|
||||
const extraQueries: QueryObject[] = [];
|
||||
if (
|
||||
metrics?.length &&
|
||||
formData.show_totals &&
|
||||
queryMode === QueryMode.Aggregate
|
||||
) {
|
||||
extraQueries.push({
|
||||
...queryObject,
|
||||
columns: [],
|
||||
row_limit: 0,
|
||||
row_offset: 0,
|
||||
post_processing: [],
|
||||
order_desc: undefined, // we don't need orderby stuff here,
|
||||
orderby: undefined, // because this query will be used for get total aggregation.
|
||||
});
|
||||
}
|
||||
|
||||
const interactiveGroupBy = formData.extra_form_data?.interactive_groupby;
|
||||
if (interactiveGroupBy && queryObject.columns) {
|
||||
@@ -445,6 +443,70 @@ const buildQuery: BuildQuery<TableChartFormData> = (
|
||||
],
|
||||
};
|
||||
}
|
||||
// Add AG Grid column filters from ownState (non-metric filters only)
|
||||
if (
|
||||
ownState.agGridSimpleFilters &&
|
||||
ownState.agGridSimpleFilters.length > 0
|
||||
) {
|
||||
// Get columns that have AG Grid filters
|
||||
const agGridFilterColumns = new Set(
|
||||
ownState.agGridSimpleFilters.map(
|
||||
(filter: { col: string }) => filter.col,
|
||||
),
|
||||
);
|
||||
|
||||
// Remove existing TEMPORAL_RANGE filters for columns that have new AG Grid filters
|
||||
// This prevents duplicate filters like "No filter" and actual date ranges
|
||||
const existingFilters = (queryObject.filters || []).filter(filter => {
|
||||
// Keep filter if it doesn't have the expected structure
|
||||
if (!filter || typeof filter !== 'object' || !filter.col) {
|
||||
return true;
|
||||
}
|
||||
// Keep filter if it's not a temporal range filter
|
||||
if (filter.op !== 'TEMPORAL_RANGE') {
|
||||
return true;
|
||||
}
|
||||
// Remove if this column has an AG Grid filter
|
||||
return !agGridFilterColumns.has(filter.col);
|
||||
});
|
||||
|
||||
queryObject = {
|
||||
...queryObject,
|
||||
filters: [...existingFilters, ...ownState.agGridSimpleFilters],
|
||||
};
|
||||
}
|
||||
|
||||
// Add AG Grid complex WHERE clause from ownState (non-metric filters)
|
||||
if (ownState.agGridComplexWhere && ownState.agGridComplexWhere.trim()) {
|
||||
const existingWhere = queryObject.extras?.where;
|
||||
const combinedWhere = existingWhere
|
||||
? `${existingWhere} AND ${ownState.agGridComplexWhere}`
|
||||
: ownState.agGridComplexWhere;
|
||||
|
||||
queryObject = {
|
||||
...queryObject,
|
||||
extras: {
|
||||
...queryObject.extras,
|
||||
where: combinedWhere,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Add AG Grid HAVING clause from ownState (metric filters only)
|
||||
if (ownState.agGridHavingClause && ownState.agGridHavingClause.trim()) {
|
||||
const existingHaving = queryObject.extras?.having;
|
||||
const combinedHaving = existingHaving
|
||||
? `${existingHaving} AND ${ownState.agGridHavingClause}`
|
||||
: ownState.agGridHavingClause;
|
||||
|
||||
queryObject = {
|
||||
...queryObject,
|
||||
extras: {
|
||||
...queryObject.extras,
|
||||
having: combinedHaving,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (isDownloadQuery) {
|
||||
@@ -481,6 +543,54 @@ const buildQuery: BuildQuery<TableChartFormData> = (
|
||||
}
|
||||
}
|
||||
|
||||
// Create totals query AFTER all filters (including AG Grid filters) are applied
|
||||
// This ensures we can properly exclude AG Grid WHERE filters from the totals
|
||||
if (
|
||||
metrics?.length &&
|
||||
formData.show_totals &&
|
||||
queryMode === QueryMode.Aggregate
|
||||
) {
|
||||
// Create a copy of extras without the AG Grid WHERE clause
|
||||
// AG Grid filters in extras.where can reference calculated columns
|
||||
// which aren't available in the totals subquery
|
||||
const totalsExtras = { ...queryObject.extras };
|
||||
if (ownState.agGridComplexWhere) {
|
||||
// Remove AG Grid WHERE clause from totals query
|
||||
const whereClause = totalsExtras.where;
|
||||
if (whereClause) {
|
||||
// Remove the AG Grid filter part from the WHERE clause using string methods
|
||||
const agGridWhere = ownState.agGridComplexWhere;
|
||||
let newWhereClause = whereClause;
|
||||
|
||||
// Try to remove with " AND " before
|
||||
newWhereClause = newWhereClause.replace(` AND ${agGridWhere}`, '');
|
||||
// Try to remove with " AND " after
|
||||
newWhereClause = newWhereClause.replace(`${agGridWhere} AND `, '');
|
||||
// If it's the only clause, remove it entirely
|
||||
if (newWhereClause === agGridWhere) {
|
||||
newWhereClause = '';
|
||||
}
|
||||
|
||||
if (newWhereClause.trim()) {
|
||||
totalsExtras.where = newWhereClause;
|
||||
} else {
|
||||
delete totalsExtras.where;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extraQueries.push({
|
||||
...queryObject,
|
||||
columns: [],
|
||||
extras: totalsExtras, // Use extras with AG Grid WHERE removed
|
||||
row_limit: 0,
|
||||
row_offset: 0,
|
||||
post_processing: [],
|
||||
order_desc: undefined, // we don't need orderby stuff here,
|
||||
orderby: undefined, // because this query will be used for get total aggregation.
|
||||
});
|
||||
}
|
||||
|
||||
// Now since row limit control is always visible even
|
||||
// in case of server pagination
|
||||
// we must use row limit from form data
|
||||
@@ -506,8 +616,8 @@ const buildQuery: BuildQuery<TableChartFormData> = (
|
||||
// Use this closure to cache changing of external filters, if we have server pagination we need reset page to 0, after
|
||||
// external filter changed
|
||||
export const cachedBuildQuery = (): BuildQuery<TableChartFormData> => {
|
||||
let cachedChanges: any = {};
|
||||
const setCachedChanges = (newChanges: any) => {
|
||||
let cachedChanges: Record<string, unknown> = {};
|
||||
const setCachedChanges = (newChanges: Record<string, unknown>) => {
|
||||
cachedChanges = { ...cachedChanges, ...newChanges };
|
||||
};
|
||||
|
||||
|
||||
@@ -27,3 +27,18 @@ export const PAGE_SIZE_OPTIONS = [10, 20, 50, 100, 200];
|
||||
export const CUSTOM_AGG_FUNCS = {
|
||||
queryTotal: 'Metric total',
|
||||
};
|
||||
|
||||
export const FILTER_POPOVER_OPEN_DELAY = 200;
|
||||
export const FILTER_INPUT_SELECTOR = 'input[data-ref="eInput"]';
|
||||
export const NOOP_FILTER_COMPARATOR = () => 0;
|
||||
|
||||
export const FILTER_INPUT_POSITIONS = {
|
||||
FIRST: 'first' as const,
|
||||
SECOND: 'second' as const,
|
||||
UNKNOWN: 'unknown' as const,
|
||||
} as const;
|
||||
|
||||
export const FILTER_CONDITION_BODY_INDEX = {
|
||||
FIRST: 0,
|
||||
SECOND: 1,
|
||||
} as const;
|
||||
|
||||
@@ -16,9 +16,9 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { styled, useTheme } from '@apache-superset/core/ui';
|
||||
import { styled, useTheme, type SupersetTheme } from '@apache-superset/core/ui';
|
||||
import { CustomCellRendererProps } from '@superset-ui/core/components/ThemedAgGridReact';
|
||||
import { BasicColorFormatterType, InputColumn } from '../types';
|
||||
import { BasicColorFormatterType, InputColumn, ValueRange } from '../types';
|
||||
import { useIsDark } from '../utils/useTableTheme';
|
||||
|
||||
const StyledTotalCell = styled.div`
|
||||
@@ -53,8 +53,6 @@ const Bar = styled.div<{
|
||||
z-index: 1;
|
||||
`;
|
||||
|
||||
type ValueRange = [number, number];
|
||||
|
||||
/**
|
||||
* Cell background width calculation for horizontal bar chart
|
||||
*/
|
||||
@@ -64,7 +62,7 @@ function cellWidth({
|
||||
alignPositiveNegative,
|
||||
}: {
|
||||
value: number;
|
||||
valueRange: ValueRange;
|
||||
valueRange: [number, number];
|
||||
alignPositiveNegative: boolean;
|
||||
}) {
|
||||
const [minValue, maxValue] = valueRange;
|
||||
@@ -89,7 +87,7 @@ function cellOffset({
|
||||
alignPositiveNegative,
|
||||
}: {
|
||||
value: number;
|
||||
valueRange: ValueRange;
|
||||
valueRange: [number, number];
|
||||
alignPositiveNegative: boolean;
|
||||
}) {
|
||||
if (alignPositiveNegative) {
|
||||
@@ -114,7 +112,7 @@ function cellBackground({
|
||||
value: number;
|
||||
colorPositiveNegative: boolean;
|
||||
isDarkTheme: boolean;
|
||||
theme: any;
|
||||
theme: SupersetTheme | null;
|
||||
}) {
|
||||
if (!colorPositiveNegative) {
|
||||
return 'transparent'; // Use transparent background when colorPositiveNegative is false
|
||||
@@ -134,7 +132,7 @@ export const NumericCellRenderer = (
|
||||
basicColorFormatters: {
|
||||
[Key: string]: BasicColorFormatterType;
|
||||
}[];
|
||||
valueRange: any;
|
||||
valueRange: ValueRange;
|
||||
alignPositiveNegative: boolean;
|
||||
colorPositiveNegative: boolean;
|
||||
},
|
||||
|
||||
@@ -25,30 +25,46 @@ import {
|
||||
type AgGridFilterModel,
|
||||
type AgGridFilter,
|
||||
} from '@superset-ui/core';
|
||||
import {
|
||||
getStartOfDay,
|
||||
getEndOfDay,
|
||||
FILTER_OPERATORS,
|
||||
SQL_OPERATORS,
|
||||
validateColumnName,
|
||||
} from './utils/agGridFilterConverter';
|
||||
|
||||
/**
|
||||
* AG Grid text filter type to backend operator mapping
|
||||
* Maps custom server-side date filter operators to normalized operator names.
|
||||
* Server-side operators (serverEquals, serverBefore, etc.) are custom operators
|
||||
* used when server_pagination is enabled to bypass client-side filtering.
|
||||
*/
|
||||
const TEXT_FILTER_OPERATORS: Record<string, string> = {
|
||||
equals: '==',
|
||||
notEqual: '!=',
|
||||
contains: 'ILIKE',
|
||||
notContains: 'NOT ILIKE',
|
||||
startsWith: 'ILIKE',
|
||||
endsWith: 'ILIKE',
|
||||
const DATE_FILTER_OPERATOR_MAP: Record<string, string> = {
|
||||
// Standard operators
|
||||
[FILTER_OPERATORS.EQUALS]: FILTER_OPERATORS.EQUALS,
|
||||
[FILTER_OPERATORS.NOT_EQUAL]: FILTER_OPERATORS.NOT_EQUAL,
|
||||
[FILTER_OPERATORS.LESS_THAN]: FILTER_OPERATORS.LESS_THAN,
|
||||
[FILTER_OPERATORS.LESS_THAN_OR_EQUAL]: FILTER_OPERATORS.LESS_THAN_OR_EQUAL,
|
||||
[FILTER_OPERATORS.GREATER_THAN]: FILTER_OPERATORS.GREATER_THAN,
|
||||
[FILTER_OPERATORS.GREATER_THAN_OR_EQUAL]:
|
||||
FILTER_OPERATORS.GREATER_THAN_OR_EQUAL,
|
||||
[FILTER_OPERATORS.IN_RANGE]: FILTER_OPERATORS.IN_RANGE,
|
||||
// Custom server-side operators (map to standard equivalents)
|
||||
[FILTER_OPERATORS.SERVER_EQUALS]: FILTER_OPERATORS.EQUALS,
|
||||
[FILTER_OPERATORS.SERVER_NOT_EQUAL]: FILTER_OPERATORS.NOT_EQUAL,
|
||||
[FILTER_OPERATORS.SERVER_BEFORE]: FILTER_OPERATORS.LESS_THAN,
|
||||
[FILTER_OPERATORS.SERVER_AFTER]: FILTER_OPERATORS.GREATER_THAN,
|
||||
[FILTER_OPERATORS.SERVER_IN_RANGE]: FILTER_OPERATORS.IN_RANGE,
|
||||
};
|
||||
|
||||
/**
|
||||
* AG Grid number filter type to backend operator mapping
|
||||
* Blank filter operator types
|
||||
*/
|
||||
const NUMBER_FILTER_OPERATORS: Record<string, string> = {
|
||||
equals: '==',
|
||||
notEqual: '!=',
|
||||
lessThan: '<',
|
||||
lessThanOrEqual: '<=',
|
||||
greaterThan: '>',
|
||||
greaterThanOrEqual: '>=',
|
||||
};
|
||||
const BLANK_OPERATORS: Set<string> = new Set([
|
||||
FILTER_OPERATORS.BLANK,
|
||||
FILTER_OPERATORS.NOT_BLANK,
|
||||
FILTER_OPERATORS.SERVER_BLANK,
|
||||
FILTER_OPERATORS.SERVER_NOT_BLANK,
|
||||
]);
|
||||
|
||||
/** Escapes single quotes in SQL strings: O'Hara → O''Hara */
|
||||
function escapeStringValue(value: string): string {
|
||||
@@ -56,18 +72,77 @@ function escapeStringValue(value: string): string {
|
||||
}
|
||||
|
||||
function getTextComparator(type: string, value: string): string {
|
||||
if (type === 'contains' || type === 'notContains') {
|
||||
if (
|
||||
type === FILTER_OPERATORS.CONTAINS ||
|
||||
type === FILTER_OPERATORS.NOT_CONTAINS
|
||||
) {
|
||||
return `%${value}%`;
|
||||
}
|
||||
if (type === 'startsWith') {
|
||||
if (type === FILTER_OPERATORS.STARTS_WITH) {
|
||||
return `${value}%`;
|
||||
}
|
||||
if (type === 'endsWith') {
|
||||
if (type === FILTER_OPERATORS.ENDS_WITH) {
|
||||
return `%${value}`;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a date filter to SQL clause.
|
||||
* Handles both standard operators (equals, lessThan, etc.) and
|
||||
* custom server-side operators (serverEquals, serverBefore, etc.).
|
||||
*
|
||||
* @param colId - Column identifier
|
||||
* @param filter - AG Grid date filter object
|
||||
* @returns SQL clause string or null if conversion not possible
|
||||
*/
|
||||
function convertDateFilterToSQL(
|
||||
colId: string,
|
||||
filter: AgGridFilter,
|
||||
): string | null {
|
||||
const { type, dateFrom, dateTo } = filter;
|
||||
|
||||
if (!type) return null;
|
||||
|
||||
// Map custom server operators to standard ones
|
||||
const normalizedType = DATE_FILTER_OPERATOR_MAP[type] || type;
|
||||
|
||||
switch (normalizedType) {
|
||||
case FILTER_OPERATORS.EQUALS:
|
||||
if (!dateFrom) return null;
|
||||
// Full day range for equals
|
||||
return `(${colId} >= '${getStartOfDay(dateFrom)}' AND ${colId} <= '${getEndOfDay(dateFrom)}')`;
|
||||
|
||||
case FILTER_OPERATORS.NOT_EQUAL:
|
||||
if (!dateFrom) return null;
|
||||
// Outside the full day range for not equals
|
||||
return `(${colId} < '${getStartOfDay(dateFrom)}' OR ${colId} > '${getEndOfDay(dateFrom)}')`;
|
||||
|
||||
case FILTER_OPERATORS.LESS_THAN:
|
||||
if (!dateFrom) return null;
|
||||
return `${colId} < '${getStartOfDay(dateFrom)}'`;
|
||||
|
||||
case FILTER_OPERATORS.LESS_THAN_OR_EQUAL:
|
||||
if (!dateFrom) return null;
|
||||
return `${colId} <= '${getEndOfDay(dateFrom)}'`;
|
||||
|
||||
case FILTER_OPERATORS.GREATER_THAN:
|
||||
if (!dateFrom) return null;
|
||||
return `${colId} > '${getEndOfDay(dateFrom)}'`;
|
||||
|
||||
case FILTER_OPERATORS.GREATER_THAN_OR_EQUAL:
|
||||
if (!dateFrom) return null;
|
||||
return `${colId} >= '${getStartOfDay(dateFrom)}'`;
|
||||
|
||||
case FILTER_OPERATORS.IN_RANGE:
|
||||
if (!dateFrom || !dateTo) return null;
|
||||
return `${colId} BETWEEN '${getStartOfDay(dateFrom)}' AND '${getEndOfDay(dateTo)}'`;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts AG Grid sortModel to backend sortBy format
|
||||
*/
|
||||
@@ -111,11 +186,18 @@ export function convertColumnState(
|
||||
* - Complex: {operator: 'AND', condition1: {type: 'greaterThan', filter: 1}, condition2: {type: 'lessThan', filter: 16}}
|
||||
* → "(column_name > 1 AND column_name < 16)"
|
||||
* - Set: {filterType: 'set', values: ['a', 'b']} → "column_name IN ('a', 'b')"
|
||||
* - Blank: {filterType: 'text', type: 'blank'} → "column_name IS NULL"
|
||||
* - Date: {filterType: 'date', type: 'serverBefore', dateFrom: '2024-01-01'} → "column_name < '2024-01-01T00:00:00'"
|
||||
*/
|
||||
function convertFilterToSQL(
|
||||
colId: string,
|
||||
filter: AgGridFilter,
|
||||
): string | null {
|
||||
// Validate column name to prevent SQL injection and malformed queries
|
||||
if (!validateColumnName(colId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Complex filter: has operator and conditions
|
||||
if (
|
||||
filter.operator &&
|
||||
@@ -137,14 +219,43 @@ function convertFilterToSQL(
|
||||
return `(${conditions.join(` ${filter.operator} `)})`;
|
||||
}
|
||||
|
||||
// Handle blank/notBlank operators for all filter types
|
||||
// These are special operators that check for NULL values
|
||||
if (filter.type && BLANK_OPERATORS.has(filter.type)) {
|
||||
if (
|
||||
filter.type === FILTER_OPERATORS.BLANK ||
|
||||
filter.type === FILTER_OPERATORS.SERVER_BLANK
|
||||
) {
|
||||
return `${colId} ${SQL_OPERATORS.IS_NULL}`;
|
||||
}
|
||||
if (
|
||||
filter.type === FILTER_OPERATORS.NOT_BLANK ||
|
||||
filter.type === FILTER_OPERATORS.SERVER_NOT_BLANK
|
||||
) {
|
||||
return `${colId} ${SQL_OPERATORS.IS_NOT_NULL}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (filter.filterType === 'text' && filter.filter && filter.type) {
|
||||
const op = TEXT_FILTER_OPERATORS[filter.type];
|
||||
const escapedFilter = escapeStringValue(String(filter.filter));
|
||||
const val = getTextComparator(filter.type, escapedFilter);
|
||||
|
||||
return op === 'ILIKE' || op === 'NOT ILIKE'
|
||||
? `${colId} ${op} '${val}'`
|
||||
: `${colId} ${op} '${escapedFilter}'`;
|
||||
// Map text filter types to SQL operators
|
||||
switch (filter.type) {
|
||||
case FILTER_OPERATORS.EQUALS:
|
||||
return `${colId} ${SQL_OPERATORS.EQUALS} '${escapedFilter}'`;
|
||||
case FILTER_OPERATORS.NOT_EQUAL:
|
||||
return `${colId} ${SQL_OPERATORS.NOT_EQUALS} '${escapedFilter}'`;
|
||||
case FILTER_OPERATORS.CONTAINS:
|
||||
return `${colId} ${SQL_OPERATORS.ILIKE} '${val}'`;
|
||||
case FILTER_OPERATORS.NOT_CONTAINS:
|
||||
return `${colId} ${SQL_OPERATORS.NOT_ILIKE} '${val}'`;
|
||||
case FILTER_OPERATORS.STARTS_WITH:
|
||||
case FILTER_OPERATORS.ENDS_WITH:
|
||||
return `${colId} ${SQL_OPERATORS.ILIKE} '${val}'`;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -152,14 +263,28 @@ function convertFilterToSQL(
|
||||
filter.filter !== undefined &&
|
||||
filter.type
|
||||
) {
|
||||
const op = NUMBER_FILTER_OPERATORS[filter.type];
|
||||
return `${colId} ${op} ${filter.filter}`;
|
||||
// Map number filter types to SQL operators
|
||||
switch (filter.type) {
|
||||
case FILTER_OPERATORS.EQUALS:
|
||||
return `${colId} ${SQL_OPERATORS.EQUALS} ${filter.filter}`;
|
||||
case FILTER_OPERATORS.NOT_EQUAL:
|
||||
return `${colId} ${SQL_OPERATORS.NOT_EQUALS} ${filter.filter}`;
|
||||
case FILTER_OPERATORS.LESS_THAN:
|
||||
return `${colId} ${SQL_OPERATORS.LESS_THAN} ${filter.filter}`;
|
||||
case FILTER_OPERATORS.LESS_THAN_OR_EQUAL:
|
||||
return `${colId} ${SQL_OPERATORS.LESS_THAN_OR_EQUAL} ${filter.filter}`;
|
||||
case FILTER_OPERATORS.GREATER_THAN:
|
||||
return `${colId} ${SQL_OPERATORS.GREATER_THAN} ${filter.filter}`;
|
||||
case FILTER_OPERATORS.GREATER_THAN_OR_EQUAL:
|
||||
return `${colId} ${SQL_OPERATORS.GREATER_THAN_OR_EQUAL} ${filter.filter}`;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (filter.filterType === 'date' && filter.dateFrom && filter.type) {
|
||||
const op = NUMBER_FILTER_OPERATORS[filter.type];
|
||||
const escapedDate = escapeStringValue(filter.dateFrom);
|
||||
return `${colId} ${op} '${escapedDate}'`;
|
||||
// Handle date filters with proper date formatting and custom server operators
|
||||
if (filter.filterType === 'date' && filter.type) {
|
||||
return convertDateFilterToSQL(colId, filter);
|
||||
}
|
||||
|
||||
if (
|
||||
|
||||
@@ -191,9 +191,20 @@ export interface SortState {
|
||||
sort: 'asc' | 'desc' | null;
|
||||
}
|
||||
|
||||
export type FilterInputPosition = 'first' | 'second' | 'unknown';
|
||||
|
||||
export interface AGGridFilterInstance {
|
||||
eGui?: HTMLElement;
|
||||
eConditionBodies?: HTMLElement[];
|
||||
eJoinAnds?: Array<{ eGui?: HTMLElement }>;
|
||||
eJoinOrs?: Array<{ eGui?: HTMLElement }>;
|
||||
}
|
||||
|
||||
export interface CustomContext {
|
||||
initialSortState: SortState[];
|
||||
onColumnHeaderClicked: (args: { column: SortState }) => void;
|
||||
lastFilteredColumn?: string;
|
||||
lastFilteredInputPosition?: FilterInputPosition;
|
||||
}
|
||||
|
||||
export interface CustomHeaderParams extends IHeaderParams {
|
||||
@@ -226,19 +237,25 @@ export interface InputColumn {
|
||||
isNumeric: boolean;
|
||||
isMetric: boolean;
|
||||
isPercentMetric: boolean;
|
||||
config: Record<string, any>;
|
||||
formatter?: Function;
|
||||
config: TableColumnConfig;
|
||||
formatter?:
|
||||
| TimeFormatter
|
||||
| NumberFormatter
|
||||
| CustomFormatter
|
||||
| CurrencyFormatter;
|
||||
originalLabel?: string;
|
||||
metricName?: string;
|
||||
}
|
||||
|
||||
export type ValueRange = [number, number] | null;
|
||||
|
||||
export type CellRendererProps = CustomCellRendererProps & {
|
||||
hasBasicColorFormatters: boolean | undefined;
|
||||
col: InputColumn;
|
||||
basicColorFormatters: {
|
||||
[Key: string]: BasicColorFormatterType;
|
||||
}[];
|
||||
valueRange: any;
|
||||
valueRange: ValueRange;
|
||||
alignPositiveNegative: boolean;
|
||||
colorPositiveNegative: boolean;
|
||||
allowRenderHtml: boolean;
|
||||
|
||||
@@ -0,0 +1,726 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export type AgGridFilterType = 'text' | 'number' | 'date' | 'set' | 'boolean';
|
||||
|
||||
export type AgGridFilterOperator =
|
||||
| 'equals'
|
||||
| 'notEqual'
|
||||
| 'contains'
|
||||
| 'notContains'
|
||||
| 'startsWith'
|
||||
| 'endsWith'
|
||||
| 'lessThan'
|
||||
| 'lessThanOrEqual'
|
||||
| 'greaterThan'
|
||||
| 'greaterThanOrEqual'
|
||||
| 'inRange'
|
||||
| 'blank'
|
||||
| 'notBlank'
|
||||
// Custom server-side date filter operators (always pass client-side filtering)
|
||||
| 'serverEquals'
|
||||
| 'serverNotEqual'
|
||||
| 'serverBefore'
|
||||
| 'serverAfter'
|
||||
| 'serverInRange'
|
||||
| 'serverBlank'
|
||||
| 'serverNotBlank';
|
||||
|
||||
export type AgGridLogicalOperator = 'AND' | 'OR';
|
||||
|
||||
export const FILTER_OPERATORS = {
|
||||
EQUALS: 'equals' as const,
|
||||
NOT_EQUAL: 'notEqual' as const,
|
||||
CONTAINS: 'contains' as const,
|
||||
NOT_CONTAINS: 'notContains' as const,
|
||||
STARTS_WITH: 'startsWith' as const,
|
||||
ENDS_WITH: 'endsWith' as const,
|
||||
LESS_THAN: 'lessThan' as const,
|
||||
LESS_THAN_OR_EQUAL: 'lessThanOrEqual' as const,
|
||||
GREATER_THAN: 'greaterThan' as const,
|
||||
GREATER_THAN_OR_EQUAL: 'greaterThanOrEqual' as const,
|
||||
IN_RANGE: 'inRange' as const,
|
||||
BLANK: 'blank' as const,
|
||||
NOT_BLANK: 'notBlank' as const,
|
||||
// Custom server-side date filter operators
|
||||
SERVER_EQUALS: 'serverEquals' as const,
|
||||
SERVER_NOT_EQUAL: 'serverNotEqual' as const,
|
||||
SERVER_BEFORE: 'serverBefore' as const,
|
||||
SERVER_AFTER: 'serverAfter' as const,
|
||||
SERVER_IN_RANGE: 'serverInRange' as const,
|
||||
SERVER_BLANK: 'serverBlank' as const,
|
||||
SERVER_NOT_BLANK: 'serverNotBlank' as const,
|
||||
} as const;
|
||||
|
||||
export const SQL_OPERATORS = {
|
||||
EQUALS: '=',
|
||||
NOT_EQUALS: '!=',
|
||||
ILIKE: 'ILIKE',
|
||||
NOT_ILIKE: 'NOT ILIKE',
|
||||
LESS_THAN: '<',
|
||||
LESS_THAN_OR_EQUAL: '<=',
|
||||
GREATER_THAN: '>',
|
||||
GREATER_THAN_OR_EQUAL: '>=',
|
||||
BETWEEN: 'BETWEEN',
|
||||
IS_NULL: 'IS NULL',
|
||||
IS_NOT_NULL: 'IS NOT NULL',
|
||||
IN: 'IN',
|
||||
TEMPORAL_RANGE: 'TEMPORAL_RANGE',
|
||||
} as const;
|
||||
|
||||
export type FilterValue = string | number | boolean | Date | null;
|
||||
|
||||
// Regex for validating column names. Allows:
|
||||
// - Alphanumeric chars, underscores, dots, spaces (standard column names)
|
||||
// - Parentheses for aggregate functions like COUNT(*)
|
||||
// - % for LIKE patterns, * for wildcards, + - / for computed columns
|
||||
const COLUMN_NAME_REGEX = /^[a-zA-Z0-9_. ()%*+\-/]+$/;
|
||||
|
||||
export interface AgGridSimpleFilter {
|
||||
filterType: AgGridFilterType;
|
||||
type: AgGridFilterOperator;
|
||||
filter?: FilterValue;
|
||||
filterTo?: FilterValue;
|
||||
// Date filter properties
|
||||
dateFrom?: string | null;
|
||||
dateTo?: string | null;
|
||||
}
|
||||
|
||||
export interface AgGridCompoundFilter {
|
||||
filterType: AgGridFilterType;
|
||||
operator: AgGridLogicalOperator;
|
||||
condition1: AgGridSimpleFilter;
|
||||
condition2: AgGridSimpleFilter;
|
||||
conditions?: AgGridSimpleFilter[];
|
||||
}
|
||||
|
||||
export interface AgGridSetFilter {
|
||||
filterType: 'set';
|
||||
values: FilterValue[];
|
||||
}
|
||||
|
||||
export type AgGridFilterModel = Record<
|
||||
string,
|
||||
AgGridSimpleFilter | AgGridCompoundFilter | AgGridSetFilter
|
||||
>;
|
||||
|
||||
export interface SQLAlchemyFilter {
|
||||
col: string;
|
||||
op: string;
|
||||
val: FilterValue | FilterValue[];
|
||||
}
|
||||
|
||||
export interface ConvertedFilter {
|
||||
simpleFilters: SQLAlchemyFilter[];
|
||||
complexWhere?: string;
|
||||
havingClause?: string;
|
||||
}
|
||||
|
||||
const AG_GRID_TO_SQLA_OPERATOR_MAP: Record<AgGridFilterOperator, string> = {
|
||||
[FILTER_OPERATORS.EQUALS]: SQL_OPERATORS.EQUALS,
|
||||
[FILTER_OPERATORS.NOT_EQUAL]: SQL_OPERATORS.NOT_EQUALS,
|
||||
[FILTER_OPERATORS.CONTAINS]: SQL_OPERATORS.ILIKE,
|
||||
[FILTER_OPERATORS.NOT_CONTAINS]: SQL_OPERATORS.NOT_ILIKE,
|
||||
[FILTER_OPERATORS.STARTS_WITH]: SQL_OPERATORS.ILIKE,
|
||||
[FILTER_OPERATORS.ENDS_WITH]: SQL_OPERATORS.ILIKE,
|
||||
[FILTER_OPERATORS.LESS_THAN]: SQL_OPERATORS.LESS_THAN,
|
||||
[FILTER_OPERATORS.LESS_THAN_OR_EQUAL]: SQL_OPERATORS.LESS_THAN_OR_EQUAL,
|
||||
[FILTER_OPERATORS.GREATER_THAN]: SQL_OPERATORS.GREATER_THAN,
|
||||
[FILTER_OPERATORS.GREATER_THAN_OR_EQUAL]: SQL_OPERATORS.GREATER_THAN_OR_EQUAL,
|
||||
[FILTER_OPERATORS.IN_RANGE]: SQL_OPERATORS.BETWEEN,
|
||||
[FILTER_OPERATORS.BLANK]: SQL_OPERATORS.IS_NULL,
|
||||
[FILTER_OPERATORS.NOT_BLANK]: SQL_OPERATORS.IS_NOT_NULL,
|
||||
// Server-side date filter operators (map to same SQL operators as standard ones)
|
||||
[FILTER_OPERATORS.SERVER_EQUALS]: SQL_OPERATORS.EQUALS,
|
||||
[FILTER_OPERATORS.SERVER_NOT_EQUAL]: SQL_OPERATORS.NOT_EQUALS,
|
||||
[FILTER_OPERATORS.SERVER_BEFORE]: SQL_OPERATORS.LESS_THAN,
|
||||
[FILTER_OPERATORS.SERVER_AFTER]: SQL_OPERATORS.GREATER_THAN,
|
||||
[FILTER_OPERATORS.SERVER_IN_RANGE]: SQL_OPERATORS.BETWEEN,
|
||||
[FILTER_OPERATORS.SERVER_BLANK]: SQL_OPERATORS.IS_NULL,
|
||||
[FILTER_OPERATORS.SERVER_NOT_BLANK]: SQL_OPERATORS.IS_NOT_NULL,
|
||||
};
|
||||
|
||||
/**
|
||||
* Escapes single quotes in SQL strings to prevent SQL injection
|
||||
* @param value - String value to escape
|
||||
* @returns Escaped string safe for SQL queries
|
||||
*/
|
||||
function escapeSQLString(value: string): string {
|
||||
return value.replace(/'/g, "''");
|
||||
}
|
||||
|
||||
// Maximum column name length - conservative upper bound that exceeds all common
|
||||
// database identifier limits (MySQL: 64, PostgreSQL: 63, SQL Server: 128, Oracle: 128)
|
||||
const MAX_COLUMN_NAME_LENGTH = 255;
|
||||
|
||||
/**
|
||||
* Validates a column name to prevent SQL injection
|
||||
* Checks for: non-empty string, length limit, allowed characters
|
||||
*/
|
||||
export function validateColumnName(columnName: string): boolean {
|
||||
if (!columnName || typeof columnName !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (columnName.length > MAX_COLUMN_NAME_LENGTH) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!COLUMN_NAME_REGEX.test(columnName)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a filter value for a given operator
|
||||
* BLANK and NOT_BLANK operators don't require values
|
||||
* @param value - Filter value to validate
|
||||
* @param operator - AG Grid filter operator
|
||||
* @returns True if the value is valid for the operator, false otherwise
|
||||
*/
|
||||
function validateFilterValue(
|
||||
value: FilterValue | undefined,
|
||||
operator: AgGridFilterOperator,
|
||||
): boolean {
|
||||
if (
|
||||
operator === FILTER_OPERATORS.BLANK ||
|
||||
operator === FILTER_OPERATORS.NOT_BLANK
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (value === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const valueType = typeof value;
|
||||
if (
|
||||
value !== null &&
|
||||
valueType !== 'string' &&
|
||||
valueType !== 'number' &&
|
||||
valueType !== 'boolean' &&
|
||||
!(value instanceof Date)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function formatValueForOperator(
|
||||
operator: AgGridFilterOperator,
|
||||
value: FilterValue,
|
||||
): FilterValue {
|
||||
if (typeof value === 'string') {
|
||||
if (
|
||||
operator === FILTER_OPERATORS.CONTAINS ||
|
||||
operator === FILTER_OPERATORS.NOT_CONTAINS
|
||||
) {
|
||||
return `%${value}%`;
|
||||
}
|
||||
if (operator === FILTER_OPERATORS.STARTS_WITH) {
|
||||
return `${value}%`;
|
||||
}
|
||||
if (operator === FILTER_OPERATORS.ENDS_WITH) {
|
||||
return `%${value}`;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a date filter to a WHERE clause
|
||||
* @param columnName - Column name
|
||||
* @param filter - AG Grid date filter
|
||||
* @returns WHERE clause string for date filter
|
||||
*/
|
||||
function dateFilterToWhereClause(
|
||||
columnName: string,
|
||||
filter: AgGridSimpleFilter,
|
||||
): string {
|
||||
const { type, dateFrom, dateTo, filter: filterValue, filterTo } = filter;
|
||||
|
||||
// Support both dateFrom/dateTo and filter/filterTo
|
||||
const fromDate = dateFrom || (filterValue as string);
|
||||
const toDate = dateTo || (filterTo as string);
|
||||
|
||||
// Convert based on operator type
|
||||
switch (type) {
|
||||
case FILTER_OPERATORS.EQUALS:
|
||||
if (!fromDate) return '';
|
||||
// For equals, check if date is within the full day range
|
||||
return `(${columnName} >= '${getStartOfDay(fromDate)}' AND ${columnName} <= '${getEndOfDay(fromDate)}')`;
|
||||
|
||||
case FILTER_OPERATORS.NOT_EQUAL:
|
||||
if (!fromDate) return '';
|
||||
// For not equals, exclude the full day range
|
||||
return `(${columnName} < '${getStartOfDay(fromDate)}' OR ${columnName} > '${getEndOfDay(fromDate)}')`;
|
||||
|
||||
case FILTER_OPERATORS.LESS_THAN:
|
||||
if (!fromDate) return '';
|
||||
return `${columnName} < '${getStartOfDay(fromDate)}'`;
|
||||
|
||||
case FILTER_OPERATORS.LESS_THAN_OR_EQUAL:
|
||||
if (!fromDate) return '';
|
||||
return `${columnName} <= '${getEndOfDay(fromDate)}'`;
|
||||
|
||||
case FILTER_OPERATORS.GREATER_THAN:
|
||||
if (!fromDate) return '';
|
||||
return `${columnName} > '${getEndOfDay(fromDate)}'`;
|
||||
|
||||
case FILTER_OPERATORS.GREATER_THAN_OR_EQUAL:
|
||||
if (!fromDate) return '';
|
||||
return `${columnName} >= '${getStartOfDay(fromDate)}'`;
|
||||
|
||||
case FILTER_OPERATORS.IN_RANGE:
|
||||
if (!fromDate || !toDate) return '';
|
||||
return `${columnName} ${SQL_OPERATORS.BETWEEN} '${getStartOfDay(fromDate)}' AND '${getEndOfDay(toDate)}'`;
|
||||
|
||||
case FILTER_OPERATORS.BLANK:
|
||||
return `${columnName} ${SQL_OPERATORS.IS_NULL}`;
|
||||
|
||||
case FILTER_OPERATORS.NOT_BLANK:
|
||||
return `${columnName} ${SQL_OPERATORS.IS_NOT_NULL}`;
|
||||
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function simpleFilterToWhereClause(
|
||||
columnName: string,
|
||||
filter: AgGridSimpleFilter,
|
||||
): string {
|
||||
// Check if this is a date filter and handle it specially
|
||||
if (filter.filterType === 'date') {
|
||||
return dateFilterToWhereClause(columnName, filter);
|
||||
}
|
||||
|
||||
const { type, filter: value, filterTo } = filter;
|
||||
|
||||
const operator = AG_GRID_TO_SQLA_OPERATOR_MAP[type];
|
||||
if (!operator) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (!validateFilterValue(value, type)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (type === FILTER_OPERATORS.BLANK) {
|
||||
return `${columnName} ${SQL_OPERATORS.IS_NULL}`;
|
||||
}
|
||||
|
||||
if (type === FILTER_OPERATORS.NOT_BLANK) {
|
||||
return `${columnName} ${SQL_OPERATORS.IS_NOT_NULL}`;
|
||||
}
|
||||
|
||||
if (value === null || value === undefined) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (type === FILTER_OPERATORS.IN_RANGE && filterTo !== undefined) {
|
||||
return `${columnName} ${SQL_OPERATORS.BETWEEN} ${value} AND ${filterTo}`;
|
||||
}
|
||||
|
||||
const formattedValue = formatValueForOperator(type, value!);
|
||||
|
||||
if (
|
||||
operator === SQL_OPERATORS.ILIKE ||
|
||||
operator === SQL_OPERATORS.NOT_ILIKE
|
||||
) {
|
||||
return `${columnName} ${operator} '${escapeSQLString(String(formattedValue))}'`;
|
||||
}
|
||||
|
||||
if (typeof formattedValue === 'string') {
|
||||
return `${columnName} ${operator} '${escapeSQLString(formattedValue)}'`;
|
||||
}
|
||||
|
||||
return `${columnName} ${operator} ${formattedValue}`;
|
||||
}
|
||||
|
||||
function isCompoundFilter(
|
||||
filter: AgGridSimpleFilter | AgGridCompoundFilter | AgGridSetFilter,
|
||||
): filter is AgGridCompoundFilter {
|
||||
return (
|
||||
'operator' in filter && ('condition1' in filter || 'conditions' in filter)
|
||||
);
|
||||
}
|
||||
|
||||
function isSetFilter(
|
||||
filter: AgGridSimpleFilter | AgGridCompoundFilter | AgGridSetFilter,
|
||||
): filter is AgGridSetFilter {
|
||||
return filter.filterType === 'set' && 'values' in filter;
|
||||
}
|
||||
|
||||
function compoundFilterToWhereClause(
|
||||
columnName: string,
|
||||
filter: AgGridCompoundFilter,
|
||||
): string {
|
||||
const { operator, condition1, condition2, conditions } = filter;
|
||||
|
||||
if (conditions && conditions.length > 0) {
|
||||
const clauses = conditions
|
||||
.map(cond => {
|
||||
const clause = simpleFilterToWhereClause(columnName, cond);
|
||||
|
||||
return clause;
|
||||
})
|
||||
.filter(clause => clause !== '');
|
||||
|
||||
if (clauses.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (clauses.length === 1) {
|
||||
return clauses[0];
|
||||
}
|
||||
|
||||
const result = `(${clauses.join(` ${operator} `)})`;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
const clause1 = simpleFilterToWhereClause(columnName, condition1);
|
||||
const clause2 = simpleFilterToWhereClause(columnName, condition2);
|
||||
|
||||
if (!clause1 && !clause2) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (!clause1) {
|
||||
return clause2;
|
||||
}
|
||||
|
||||
if (!clause2) {
|
||||
return clause1;
|
||||
}
|
||||
|
||||
const result = `(${clause1} ${operator} ${clause2})`;
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date string to ISO format expected by Superset, preserving local timezone
|
||||
*/
|
||||
export function formatDateForSuperset(dateStr: string): string {
|
||||
// AG Grid typically provides dates in format: "YYYY-MM-DD HH:MM:SS"
|
||||
// Superset expects: "YYYY-MM-DDTHH:MM:SS" in local timezone (not UTC)
|
||||
const date = new Date(dateStr);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return dateStr; // Return as-is if invalid
|
||||
}
|
||||
|
||||
// Format date in local timezone, not UTC
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0');
|
||||
|
||||
const formatted = `${year}-${month}-${day}T${hours}:${minutes}:${seconds}`;
|
||||
return formatted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the start of day (00:00:00) for a given date string
|
||||
*/
|
||||
export function getStartOfDay(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
date.setHours(0, 0, 0, 0);
|
||||
return formatDateForSuperset(date.toISOString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the end of day (23:59:59) for a given date string
|
||||
*/
|
||||
export function getEndOfDay(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
date.setHours(23, 59, 59, 999);
|
||||
return formatDateForSuperset(date.toISOString());
|
||||
}
|
||||
|
||||
// Converts date filters to TEMPORAL_RANGE format for Superset backend
|
||||
function convertDateFilter(
|
||||
columnName: string,
|
||||
filter: AgGridSimpleFilter,
|
||||
): SQLAlchemyFilter | null {
|
||||
if (filter.filterType !== 'date') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { type, dateFrom, dateTo } = filter;
|
||||
|
||||
// Handle null/blank checks for date columns
|
||||
if (
|
||||
type === FILTER_OPERATORS.BLANK ||
|
||||
type === FILTER_OPERATORS.SERVER_BLANK
|
||||
) {
|
||||
return {
|
||||
col: columnName,
|
||||
op: SQL_OPERATORS.IS_NULL,
|
||||
val: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
type === FILTER_OPERATORS.NOT_BLANK ||
|
||||
type === FILTER_OPERATORS.SERVER_NOT_BLANK
|
||||
) {
|
||||
return {
|
||||
col: columnName,
|
||||
op: SQL_OPERATORS.IS_NOT_NULL,
|
||||
val: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Validate we have at least one date
|
||||
if (!dateFrom && !dateTo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let temporalRangeValue: string;
|
||||
|
||||
// Convert based on operator type
|
||||
switch (type) {
|
||||
case FILTER_OPERATORS.EQUALS:
|
||||
case FILTER_OPERATORS.SERVER_EQUALS:
|
||||
if (!dateFrom) {
|
||||
return null;
|
||||
}
|
||||
// For equals, create a range for the entire day (00:00:00 to 23:59:59)
|
||||
temporalRangeValue = `${getStartOfDay(dateFrom)} : ${getEndOfDay(dateFrom)}`;
|
||||
break;
|
||||
|
||||
case FILTER_OPERATORS.NOT_EQUAL:
|
||||
case FILTER_OPERATORS.SERVER_NOT_EQUAL:
|
||||
// NOT EQUAL for dates is complex, skip for now
|
||||
return null;
|
||||
|
||||
case FILTER_OPERATORS.LESS_THAN:
|
||||
case FILTER_OPERATORS.SERVER_BEFORE:
|
||||
if (!dateFrom) {
|
||||
return null;
|
||||
}
|
||||
// Everything before the start of this date
|
||||
temporalRangeValue = ` : ${getStartOfDay(dateFrom)}`;
|
||||
break;
|
||||
|
||||
case FILTER_OPERATORS.LESS_THAN_OR_EQUAL:
|
||||
if (!dateFrom) {
|
||||
return null;
|
||||
}
|
||||
// Everything up to and including the end of this date
|
||||
temporalRangeValue = ` : ${getEndOfDay(dateFrom)}`;
|
||||
break;
|
||||
|
||||
case FILTER_OPERATORS.GREATER_THAN:
|
||||
case FILTER_OPERATORS.SERVER_AFTER:
|
||||
if (!dateFrom) {
|
||||
return null;
|
||||
}
|
||||
// Everything after the end of this date
|
||||
temporalRangeValue = `${getEndOfDay(dateFrom)} : `;
|
||||
break;
|
||||
|
||||
case FILTER_OPERATORS.GREATER_THAN_OR_EQUAL:
|
||||
if (!dateFrom) {
|
||||
return null;
|
||||
}
|
||||
// Everything from the start of this date onwards
|
||||
temporalRangeValue = `${getStartOfDay(dateFrom)} : `;
|
||||
break;
|
||||
|
||||
case FILTER_OPERATORS.IN_RANGE:
|
||||
case FILTER_OPERATORS.SERVER_IN_RANGE:
|
||||
// Range between two dates
|
||||
if (!dateFrom || !dateTo) {
|
||||
return null;
|
||||
}
|
||||
// From start of first date to end of second date
|
||||
temporalRangeValue = `${getStartOfDay(dateFrom)} : ${getEndOfDay(dateTo)}`;
|
||||
break;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = {
|
||||
col: columnName,
|
||||
op: SQL_OPERATORS.TEMPORAL_RANGE,
|
||||
val: temporalRangeValue,
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Converts AG Grid filters to SQLAlchemy format, separating dimension (WHERE) and metric (HAVING) filters
|
||||
export function convertAgGridFiltersToSQL(
|
||||
filterModel: AgGridFilterModel,
|
||||
metricColumns: string[] = [],
|
||||
): ConvertedFilter {
|
||||
if (!filterModel || typeof filterModel !== 'object') {
|
||||
return {
|
||||
simpleFilters: [],
|
||||
complexWhere: undefined,
|
||||
havingClause: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const metricColumnsSet = new Set(metricColumns);
|
||||
const simpleFilters: SQLAlchemyFilter[] = [];
|
||||
const complexWhereClauses: string[] = [];
|
||||
const complexHavingClauses: string[] = [];
|
||||
|
||||
Object.entries(filterModel).forEach(([columnName, filter]) => {
|
||||
if (!validateColumnName(columnName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!filter || typeof filter !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
const isMetric = metricColumnsSet.has(columnName);
|
||||
|
||||
if (isSetFilter(filter)) {
|
||||
if (!Array.isArray(filter.values) || filter.values.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isMetric) {
|
||||
const values = filter.values
|
||||
.map(v => (typeof v === 'string' ? `'${escapeSQLString(v)}'` : v))
|
||||
.join(', ');
|
||||
complexHavingClauses.push(`${columnName} IN (${values})`);
|
||||
} else {
|
||||
simpleFilters.push({
|
||||
col: columnName,
|
||||
op: SQL_OPERATORS.IN,
|
||||
val: filter.values,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCompoundFilter(filter)) {
|
||||
const whereClause = compoundFilterToWhereClause(columnName, filter);
|
||||
if (whereClause) {
|
||||
if (isMetric) {
|
||||
complexHavingClauses.push(whereClause);
|
||||
} else {
|
||||
complexWhereClauses.push(whereClause);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const simpleFilter = filter as AgGridSimpleFilter;
|
||||
|
||||
// Check if this is a date filter and handle it specially
|
||||
if (simpleFilter.filterType === 'date') {
|
||||
const dateFilter = convertDateFilter(columnName, simpleFilter);
|
||||
if (dateFilter) {
|
||||
simpleFilters.push(dateFilter);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const { type, filter: value } = simpleFilter;
|
||||
|
||||
if (!type) {
|
||||
return;
|
||||
}
|
||||
|
||||
const operator = AG_GRID_TO_SQLA_OPERATOR_MAP[type];
|
||||
if (!operator) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === FILTER_OPERATORS.BLANK) {
|
||||
if (isMetric) {
|
||||
complexHavingClauses.push(`${columnName} ${SQL_OPERATORS.IS_NULL}`);
|
||||
} else {
|
||||
simpleFilters.push({
|
||||
col: columnName,
|
||||
op: SQL_OPERATORS.IS_NULL,
|
||||
val: null,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === FILTER_OPERATORS.NOT_BLANK) {
|
||||
if (isMetric) {
|
||||
complexHavingClauses.push(`${columnName} ${SQL_OPERATORS.IS_NOT_NULL}`);
|
||||
} else {
|
||||
simpleFilters.push({
|
||||
col: columnName,
|
||||
op: SQL_OPERATORS.IS_NOT_NULL,
|
||||
val: null,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validateFilterValue(value, type)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const formattedValue = formatValueForOperator(type, value!);
|
||||
|
||||
if (isMetric) {
|
||||
const sqlClause = simpleFilterToWhereClause(columnName, simpleFilter);
|
||||
if (sqlClause) {
|
||||
complexHavingClauses.push(sqlClause);
|
||||
}
|
||||
} else {
|
||||
simpleFilters.push({
|
||||
col: columnName,
|
||||
op: operator,
|
||||
val: formattedValue,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
let complexWhere;
|
||||
if (complexWhereClauses.length === 1) {
|
||||
[complexWhere] = complexWhereClauses;
|
||||
} else if (complexWhereClauses.length > 1) {
|
||||
complexWhere = `(${complexWhereClauses.join(' AND ')})`;
|
||||
}
|
||||
|
||||
let havingClause;
|
||||
if (complexHavingClauses.length === 1) {
|
||||
[havingClause] = complexHavingClauses;
|
||||
} else if (complexHavingClauses.length > 1) {
|
||||
havingClause = `(${complexHavingClauses.join(' AND ')})`;
|
||||
}
|
||||
|
||||
const result = {
|
||||
simpleFilters,
|
||||
complexWhere,
|
||||
havingClause,
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* 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 type { RefObject } from 'react';
|
||||
import { GridApi } from 'ag-grid-community';
|
||||
import { convertAgGridFiltersToSQL } from './agGridFilterConverter';
|
||||
import type {
|
||||
AgGridFilterModel,
|
||||
SQLAlchemyFilter,
|
||||
} from './agGridFilterConverter';
|
||||
import type { AgGridReact } from '@superset-ui/core/components/ThemedAgGridReact';
|
||||
import type { FilterInputPosition, AGGridFilterInstance } from '../types';
|
||||
import { FILTER_INPUT_POSITIONS, FILTER_CONDITION_BODY_INDEX } from '../consts';
|
||||
|
||||
export interface FilterState {
|
||||
originalFilterModel: AgGridFilterModel;
|
||||
simpleFilters: SQLAlchemyFilter[];
|
||||
complexWhere?: string;
|
||||
havingClause?: string;
|
||||
lastFilteredColumn?: string;
|
||||
inputPosition?: FilterInputPosition;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects which input position (first or second) was last modified in a filter.
|
||||
* Note: activeElement is captured before async operations and passed here to ensure
|
||||
* we check against the element that was focused when the detection was initiated,
|
||||
* not what might be focused after async operations complete.
|
||||
*/
|
||||
async function detectLastFilteredInput(
|
||||
gridApi: GridApi,
|
||||
filterModel: AgGridFilterModel,
|
||||
activeElement: HTMLElement,
|
||||
): Promise<{
|
||||
lastFilteredColumn?: string;
|
||||
inputPosition: FilterInputPosition;
|
||||
}> {
|
||||
let inputPosition: FilterInputPosition = FILTER_INPUT_POSITIONS.UNKNOWN;
|
||||
let lastFilteredColumn: string | undefined;
|
||||
|
||||
// Loop through filtered columns to find which one contains the active element
|
||||
for (const [colId] of Object.entries(filterModel)) {
|
||||
const filterInstance = (await gridApi.getColumnFilterInstance(
|
||||
colId,
|
||||
)) as AGGridFilterInstance | null;
|
||||
|
||||
if (!filterInstance) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (filterInstance.eConditionBodies) {
|
||||
const conditionBodies = filterInstance.eConditionBodies;
|
||||
|
||||
// Check first condition body
|
||||
if (
|
||||
conditionBodies[FILTER_CONDITION_BODY_INDEX.FIRST]?.contains(
|
||||
activeElement,
|
||||
)
|
||||
) {
|
||||
inputPosition = FILTER_INPUT_POSITIONS.FIRST;
|
||||
lastFilteredColumn = colId;
|
||||
break;
|
||||
}
|
||||
|
||||
// Check second condition body
|
||||
if (
|
||||
conditionBodies[FILTER_CONDITION_BODY_INDEX.SECOND]?.contains(
|
||||
activeElement,
|
||||
)
|
||||
) {
|
||||
inputPosition = FILTER_INPUT_POSITIONS.SECOND;
|
||||
lastFilteredColumn = colId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (filterInstance.eJoinAnds) {
|
||||
for (const joinAnd of filterInstance.eJoinAnds) {
|
||||
if (joinAnd.eGui?.contains(activeElement)) {
|
||||
inputPosition = FILTER_INPUT_POSITIONS.FIRST;
|
||||
lastFilteredColumn = colId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (lastFilteredColumn) break;
|
||||
}
|
||||
|
||||
if (filterInstance.eJoinOrs) {
|
||||
for (const joinOr of filterInstance.eJoinOrs) {
|
||||
if (joinOr.eGui?.contains(activeElement)) {
|
||||
inputPosition = FILTER_INPUT_POSITIONS.FIRST;
|
||||
lastFilteredColumn = colId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (lastFilteredColumn) break;
|
||||
}
|
||||
}
|
||||
|
||||
return { lastFilteredColumn, inputPosition };
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets complete filter state including SQL conversion and input position detection.
|
||||
*/
|
||||
export async function getCompleteFilterState(
|
||||
gridRef: RefObject<AgGridReact>,
|
||||
metricColumns: string[],
|
||||
): Promise<FilterState> {
|
||||
// Capture activeElement before any async operations to detect which input
|
||||
// was focused when the user triggered the filter change
|
||||
const activeElement = document.activeElement as HTMLElement;
|
||||
|
||||
if (!gridRef.current?.api) {
|
||||
return {
|
||||
originalFilterModel: {},
|
||||
simpleFilters: [],
|
||||
complexWhere: undefined,
|
||||
havingClause: undefined,
|
||||
lastFilteredColumn: undefined,
|
||||
inputPosition: FILTER_INPUT_POSITIONS.UNKNOWN,
|
||||
};
|
||||
}
|
||||
|
||||
const filterModel = gridRef.current.api.getFilterModel();
|
||||
|
||||
// Convert filters to SQL
|
||||
const convertedFilters = convertAgGridFiltersToSQL(
|
||||
filterModel,
|
||||
metricColumns,
|
||||
);
|
||||
|
||||
// Detect which input was last modified
|
||||
const { lastFilteredColumn, inputPosition } = await detectLastFilteredInput(
|
||||
gridRef.current.api,
|
||||
filterModel,
|
||||
activeElement,
|
||||
);
|
||||
|
||||
return {
|
||||
originalFilterModel: filterModel,
|
||||
simpleFilters: convertedFilters.simpleFilters,
|
||||
complexWhere: convertedFilters.complexWhere,
|
||||
havingClause: convertedFilters.havingClause,
|
||||
lastFilteredColumn,
|
||||
inputPosition,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* 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 { isEmpty } from 'lodash';
|
||||
import type { AgGridChartState } from '@superset-ui/core';
|
||||
|
||||
const getInitialFilterModel = (
|
||||
chartState?: Partial<AgGridChartState>,
|
||||
serverPaginationData?: Record<string, unknown>,
|
||||
serverPagination?: boolean,
|
||||
): Record<string, unknown> | undefined => {
|
||||
const chartStateFilterModel =
|
||||
chartState?.filterModel && !isEmpty(chartState.filterModel)
|
||||
? (chartState.filterModel as Record<string, unknown>)
|
||||
: undefined;
|
||||
|
||||
const serverFilterModel =
|
||||
serverPagination &&
|
||||
serverPaginationData?.agGridFilterModel &&
|
||||
!isEmpty(serverPaginationData.agGridFilterModel)
|
||||
? (serverPaginationData.agGridFilterModel as Record<string, unknown>)
|
||||
: undefined;
|
||||
|
||||
return chartStateFilterModel ?? serverFilterModel;
|
||||
};
|
||||
|
||||
export default getInitialFilterModel;
|
||||
@@ -19,7 +19,7 @@
|
||||
*/
|
||||
import { ColDef } from '@superset-ui/core/components/ThemedAgGridReact';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { DataRecord } from '@superset-ui/core';
|
||||
import { DataRecord, DataRecordValue } from '@superset-ui/core';
|
||||
import { GenericDataType } from '@apache-superset/core/api/core';
|
||||
import { ColorFormatters } from '@superset-ui/chart-controls';
|
||||
import { extent as d3Extent, max as d3Max } from 'd3-array';
|
||||
@@ -27,19 +27,22 @@ import {
|
||||
BasicColorFormatterType,
|
||||
CellRendererProps,
|
||||
InputColumn,
|
||||
ValueRange,
|
||||
} from '../types';
|
||||
import getCellClass from './getCellClass';
|
||||
import filterValueGetter from './filterValueGetter';
|
||||
import dateFilterComparator from './dateFilterComparator';
|
||||
import DateWithFormatter from './DateWithFormatter';
|
||||
import { getAggFunc } from './getAggFunc';
|
||||
import { TextCellRenderer } from '../renderers/TextCellRenderer';
|
||||
import { NumericCellRenderer } from '../renderers/NumericCellRenderer';
|
||||
import CustomHeader from '../AgGridTable/components/CustomHeader';
|
||||
import { NOOP_FILTER_COMPARATOR } from '../consts';
|
||||
import { valueFormatter, valueGetter } from './formatValue';
|
||||
import getCellStyle from './getCellStyle';
|
||||
|
||||
interface InputData {
|
||||
[key: string]: any;
|
||||
[key: string]: DataRecordValue;
|
||||
}
|
||||
|
||||
type UseColDefsProps = {
|
||||
@@ -60,8 +63,6 @@ type UseColDefsProps = {
|
||||
slice_id: number;
|
||||
};
|
||||
|
||||
type ValueRange = [number, number];
|
||||
|
||||
function getValueRange(
|
||||
key: string,
|
||||
alignPositiveNegative: boolean,
|
||||
@@ -113,6 +114,73 @@ const getFilterType = (col: InputColumn) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Filter value getter for temporal columns.
|
||||
* Returns null for DateWithFormatter objects with null input,
|
||||
* enabling AG Grid's blank filter to correctly identify null dates.
|
||||
*/
|
||||
const dateFilterValueGetter = (params: {
|
||||
data: Record<string, unknown>;
|
||||
colDef: { field?: string };
|
||||
}) => {
|
||||
const value = params.data?.[params.colDef.field as string];
|
||||
// Return null for DateWithFormatter with null input so AG Grid blank filter works
|
||||
if (value instanceof DateWithFormatter && value.input === null) {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
/**
|
||||
* Custom date filter options for server-side pagination.
|
||||
* Each option has a predicate that always returns true, allowing all rows to pass
|
||||
* client-side filtering since the actual filtering is handled by the server.
|
||||
*/
|
||||
const SERVER_SIDE_DATE_FILTER_OPTIONS = [
|
||||
{
|
||||
displayKey: 'serverEquals',
|
||||
displayName: 'Equals',
|
||||
predicate: () => true,
|
||||
numberOfInputs: 1,
|
||||
},
|
||||
{
|
||||
displayKey: 'serverNotEqual',
|
||||
displayName: 'Not Equal',
|
||||
predicate: () => true,
|
||||
numberOfInputs: 1,
|
||||
},
|
||||
{
|
||||
displayKey: 'serverBefore',
|
||||
displayName: 'Before',
|
||||
predicate: () => true,
|
||||
numberOfInputs: 1,
|
||||
},
|
||||
{
|
||||
displayKey: 'serverAfter',
|
||||
displayName: 'After',
|
||||
predicate: () => true,
|
||||
numberOfInputs: 1,
|
||||
},
|
||||
{
|
||||
displayKey: 'serverInRange',
|
||||
displayName: 'In Range',
|
||||
predicate: () => true,
|
||||
numberOfInputs: 2,
|
||||
},
|
||||
{
|
||||
displayKey: 'serverBlank',
|
||||
displayName: 'Blank',
|
||||
predicate: () => true,
|
||||
numberOfInputs: 0,
|
||||
},
|
||||
{
|
||||
displayKey: 'serverNotBlank',
|
||||
displayName: 'Not blank',
|
||||
predicate: () => true,
|
||||
numberOfInputs: 0,
|
||||
},
|
||||
];
|
||||
|
||||
function getHeaderLabel(col: InputColumn) {
|
||||
let headerLabel: string | undefined;
|
||||
|
||||
@@ -232,9 +300,16 @@ export const useColDefs = ({
|
||||
filterValueGetter,
|
||||
}),
|
||||
...(dataType === GenericDataType.Temporal && {
|
||||
filterParams: {
|
||||
comparator: dateFilterComparator,
|
||||
},
|
||||
// Use dateFilterValueGetter so AG Grid correctly identifies null dates for blank filter
|
||||
filterValueGetter: dateFilterValueGetter,
|
||||
filterParams: serverPagination
|
||||
? {
|
||||
filterOptions: SERVER_SIDE_DATE_FILTER_OPTIONS,
|
||||
comparator: NOOP_FILTER_COMPARATOR,
|
||||
}
|
||||
: {
|
||||
comparator: dateFilterComparator,
|
||||
},
|
||||
}),
|
||||
cellDataType: getCellDataType(col),
|
||||
defaultAggFunc: getAggFunc(col),
|
||||
|
||||
Reference in New Issue
Block a user