feat(ag-grid): Server Side Filtering for Column Level Filters (#35683)

This commit is contained in:
amaannawab923
2026-01-12 19:25:07 +05:30
committed by GitHub
parent 459b4cb23d
commit 4f444ae1d2
20 changed files with 4142 additions and 95 deletions

View File

@@ -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);

View File

@@ -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 && (

View File

@@ -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}

View File

@@ -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 };
};

View File

@@ -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;

View File

@@ -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;
},

View File

@@ -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 (

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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,
};
}

View File

@@ -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;

View File

@@ -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),