LCOV - code coverage report
Current view: top level - dashql-app/src/views/query_result - use_cross_filters.ts Coverage Total Hit
Test: DashQL 915828b Lines: 95.2 % 227 216
Test Date: 2026-11-06 19:30:32

            Line data    Source code
       1              : import * as React from 'react';
       2              : 
       3              : import { ComputationAction, SET_CROSS_FILTERS, TableComputationState } from '../../compute/computation_state.js';
       4              : import { CrossFilters } from '../../compute/cross_filters.js';
       5              : import { Dispatch } from '../../utils/variant.js';
       6              : import { FILTERED_COLUMN_AGGREGATION_TASK, TABLE_FILTERING_TASK, ORDINAL_COLUMN, STRING_COLUMN, OrdinalColumnAggregation, StringColumnAggregation, TableAggregation, TableFilteringTask, TaskStatus, WithFilter, ColumnAggregationTask, DataSearchTable, FilterTable } from '../../compute/computation_types.js';
       7              : import { ScalarFilter } from '../../compute/sql/sqlframe_builder.js';
       8              : import { HistogramFilterCallback } from './histogram_cell.js';
       9              : import { MostFrequentValueFilterCallback } from './mostfrequent_cell.js';
      10              : import { DataTableLayout } from './data_table_layout.js';
      11              : import { scheduleTask } from '../../compute/computation_tasks.js';
      12              : import { forEachIntegerValue } from './visible_rows.js';
      13              : 
      14              : /// Compare two scalar filter lists for equality.
      15            1 : function scalarFiltersEqual(left: ScalarFilter[], right: ScalarFilter[]): boolean {
      16            1 :     if (left.length !== right.length) {
      17            1 :         return false;
      18              :     }
      19            1 :     for (let i = 0; i < left.length; ++i) {
      20            1 :         const a = left[i];
      21            1 :         const b = right[i];
      22            1 :         if (a.fieldName !== b.fieldName || a.op !== b.op || a.value !== b.value) {
      23            1 :             return false;
      24              :         }
      25            1 :     }
      26            1 :     return true;
      27            1 : }
      28              : 
      29            1 : const pendingSchedules = new WeakMap<object, Set<string>>();
      30              : 
      31              : interface PendingFilterTask {
      32              :     key: string;
      33              :     task: TableFilteringTask;
      34              : }
      35              : 
      36              : interface FilterTaskQueue {
      37              :     activeKey: string | null;
      38              :     pending: PendingFilterTask | null;
      39              : }
      40              : 
      41            1 : const filterTaskQueues = new WeakMap<object, Map<number, FilterTaskQueue>>();
      42              : 
      43              : /// Effects in separate consumers can run from the same state snapshot. Keep a task key
      44              : /// reserved until the scheduled operation settles so only the first consumer schedules it.
      45            1 : function claimSchedule(dispatch: Dispatch<ComputationAction>, key: string): (() => void) | null {
      46            1 :     const owner = dispatch as object;
      47            1 :     let schedules = pendingSchedules.get(owner);
      48            1 :     if (schedules == null) {
      49            1 :         schedules = new Set();
      50            1 :         pendingSchedules.set(owner, schedules);
      51            1 :     }
      52            1 :     if (schedules.has(key)) {
      53            1 :         return null;
      54              :     }
      55            1 :     schedules.add(key);
      56            1 :     return () => schedules!.delete(key);
      57            1 : }
      58              : 
      59            1 : function scalarFiltersKey(filters: ScalarFilter[]): string {
      60            1 :     return filters.map(filter => `${filter.fieldName}\u0000${filter.op}\u0000${filter.value}`).join('\u0001');
      61            1 : }
      62              : 
      63              : /// Run at most one filter task per table and retain only the newest request that arrives
      64              : /// while it is running. Stale brush positions never enter the scheduler.
      65            1 : function scheduleLatestFilter(
      66            1 :     dispatch: Dispatch<ComputationAction>,
      67            1 :     key: string,
      68            1 :     task: TableFilteringTask,
      69            1 : ): void {
      70            1 :     const owner = dispatch as object;
      71            1 :     let queues = filterTaskQueues.get(owner);
      72            1 :     if (queues == null) {
      73            1 :         queues = new Map();
      74            1 :         filterTaskQueues.set(owner, queues);
      75            1 :     }
      76            1 :     let queue = queues.get(task.tableId);
      77            1 :     if (queue == null) {
      78            1 :         queue = { activeKey: null, pending: null };
      79            1 :         queues.set(task.tableId, queue);
      80            1 :     }
      81              : 
      82            1 :     if (queue.activeKey === key) {
      83            0 :         queue.pending = null;
      84            0 :         return;
      85            0 :     }
      86            1 :     if (queue.pending?.key === key) {
      87            0 :         return;
      88              :     }
      89            1 :     if (queue.activeKey != null) {
      90            1 :         queue.pending = { key, task };
      91            1 :         return;
      92            1 :     }
      93              : 
      94            1 :     const launch = (request: PendingFilterTask) => {
      95            1 :         queue!.activeKey = request.key;
      96            1 :         void scheduleTask(TABLE_FILTERING_TASK, request.task, dispatch).finally(() => {
      97            1 :             queue!.activeKey = null;
      98            1 :             const pending = queue!.pending;
      99            1 :             queue!.pending = null;
     100            1 :             if (pending != null) {
     101            1 :                 launch(pending);
     102              :             } else {
     103            1 :                 queues!.delete(request.task.tableId);
     104              :             }
     105            1 :         });
     106            1 :     };
     107            1 :     launch({ key, task });
     108            1 : }
     109              : 
     110              : export interface CrossFilterController {
     111              :     /// The active cross-filter selection (shared, read from the computation state)
     112              :     crossFilters: CrossFilters;
     113              :     /// Toggle/update a histogram filter for a column
     114              :     histogramFilter: HistogramFilterCallback;
     115              :     /// Toggle/update a most-frequent-value filter for a column
     116              :     mostFrequentValueFilter: MostFrequentValueFilterCallback;
     117              :     /// Request a filtered column aggregation for a column group
     118              :     requestFilteredColumnAggregation: (columnId: number) => void;
     119              : }
     120              : 
     121              : /// Cross-filter controller shared by every view of a table (the result table header
     122              : /// plots and the visualization tab's aggregation bar).
     123              : ///
     124              : /// The selection lives on the shared `TableComputationState.crossFilters`, so brushing
     125              : /// in any consumer updates the single selection and drives one `filterTable`. The
     126              : /// filtering effect below is guarded so that mounting this hook from multiple consumers
     127              : /// at once (e.g. the Data and Visualization tabs in split mode) does not schedule
     128              : /// duplicate filtering tasks.
     129            1 : export function useCrossFilters(
     130            1 :     computationState: TableComputationState | null,
     131            1 :     dispatchComputation: Dispatch<ComputationAction>,
     132            1 :     gridLayout: DataTableLayout,
     133            1 : ): CrossFilterController {
     134            1 :     const crossFilters = computationState?.crossFilters ?? EMPTY_CROSS_FILTERS;
     135            1 :     const tableId = computationState?.tableId ?? null;
     136              : 
     137              :     // Keep the callbacks stable by reading layout / column groups / state through refs.
     138            1 :     const gridLayoutRef = React.useRef(gridLayout);
     139            1 :     const columnGroupsRef = React.useRef(computationState?.columnGroups ?? EMPTY_COLUMN_GROUPS);
     140            1 :     const crossFiltersRef = React.useRef(crossFilters);
     141            1 :     const stateRef = React.useRef(computationState);
     142            1 :     gridLayoutRef.current = gridLayout;
     143            1 :     columnGroupsRef.current = computationState?.columnGroups ?? EMPTY_COLUMN_GROUPS;
     144            1 :     crossFiltersRef.current = crossFilters;
     145            1 :     stateRef.current = computationState;
     146              : 
     147            1 :     const histogramFilter: HistogramFilterCallback = React.useCallback((_table: TableAggregation, columnIndex: number, _column: OrdinalColumnAggregation, brush: [number, number] | null) => {
     148            1 :         if (tableId == null) {
     149            0 :             return;
     150              :         }
     151            1 :         const columnGroupId = gridLayoutRef.current.columnGroupByColumnIndex[columnIndex];
     152            1 :         const columnGroup = columnGroupsRef.current[columnGroupId];
     153            1 :         if (columnGroup == null || columnGroup.type != ORDINAL_COLUMN) {
     154            1 :             return;
     155              :         }
     156            1 :         const current = crossFiltersRef.current;
     157            1 :         if (current.containsHistogramFilter(columnGroupId, brush)) {
     158            0 :             return;
     159              :         }
     160            1 :         const cloned = current.clone();
     161            1 :         cloned.addHistogramFilter(columnGroupId, columnGroup.value, brush);
     162            1 :         dispatchComputation({ type: SET_CROSS_FILTERS, value: [tableId, cloned] });
     163            1 :     }, [dispatchComputation, tableId]);
     164              : 
     165            1 :     const mostFrequentValueFilter: MostFrequentValueFilterCallback = React.useCallback((_table: TableAggregation, columnIndex: number, _column: StringColumnAggregation, frequentValueId: number | null) => {
     166            1 :         if (tableId == null) {
     167            0 :             return;
     168              :         }
     169            1 :         const columnGroupId = gridLayoutRef.current.columnGroupByColumnIndex[columnIndex];
     170            1 :         const columnGroup = columnGroupsRef.current[columnGroupId];
     171            1 :         if (columnGroup == null || columnGroup.type != STRING_COLUMN || columnGroup.value.valueIdFieldName == null) {
     172            1 :             return;
     173              :         }
     174            1 :         const current = crossFiltersRef.current;
     175            1 :         const nextValueId = current.containsMostFrequentValueFilter(columnGroupId, frequentValueId)
     176            1 :             ? null
     177            1 :             : frequentValueId;
     178            1 :         const cloned = current.clone();
     179            1 :         cloned.addMostFrequentValueFilter(columnGroupId, columnGroup.value, nextValueId);
     180            1 :         dispatchComputation({ type: SET_CROSS_FILTERS, value: [tableId, cloned] });
     181            1 :     }, [dispatchComputation, tableId]);
     182              : 
     183            1 :     const crossFilterTransforms = React.useMemo(
     184            1 :         () => crossFilters.createFilterTransforms(),
     185            1 :         [crossFilters],
     186            1 :     );
     187              : 
     188              :     // Filter the immutable base table whenever cross-filters change.
     189              :     // Guarded so concurrent consumers of the shared selection don't double-schedule.
     190            1 :     React.useEffect(() => {
     191            1 :         if (!computationState || !computationState.dataFrame || !computationState.rowNumberColumnName) {
     192            1 :             return;
     193              :         }
     194              :         // Already reflected by the current filter table?
     195            1 :         const currentFilterFilters = computationState.tasks.filteringTask?.filters;
     196              :         const filterUpToDate =
     197            1 :             computationState.filterTable != null
     198            1 :             && computationState.filterTable.version.filterMatches(computationState.version)
     199            1 :             && currentFilterFilters != null
     200            1 :             && scalarFiltersEqual(currentFilterFilters, crossFilterTransforms);
     201            1 :         if (filterUpToDate) {
     202            1 :             return;
     203              :         }
     204              :         // Empty selection with no active filter table? Nothing to do.
     205            1 :         if (crossFilterTransforms.length === 0 && computationState.filterTable == null) {
     206            1 :             return;
     207              :         }
     208              :         // A filtering task for exactly these transforms is already running?
     209            1 :         const runningTask = computationState.tasks.filteringTask;
     210              :         const hasUpToDateRunningTask =
     211            1 :             runningTask?.progress.status === TaskStatus.TASK_RUNNING
     212            1 :             && runningTask.tableVersion.filterMatches(computationState.version)
     213            1 :             && scalarFiltersEqual(runningTask.filters, crossFilterTransforms);
     214            1 :         if (hasUpToDateRunningTask) {
     215            1 :             return;
     216              :         }
     217            1 :         const scheduleKey = `${computationState.version.toString()}:${scalarFiltersKey(crossFilterTransforms)}`;
     218              :         const filteringTask: TableFilteringTask = {
     219            1 :             tableId: computationState.tableId,
     220            1 :             tableVersion: computationState.version,
     221            1 :             inputDataTable: computationState.dataTable,
     222            1 :             inputDataTableFieldIndex: computationState.dataTableFieldsByName,
     223            1 :             inputDataFrame: computationState.dataFrame,
     224            1 :             filters: crossFilterTransforms,
     225            1 :             rowNumberColumnName: computationState.rowNumberColumnName,
     226              :         };
     227            1 :         scheduleLatestFilter(dispatchComputation, scheduleKey, filteringTask);
     228            1 :     }, [
     229            1 :         computationState?.dataFrame,
     230            1 :         computationState?.dataTable,
     231            1 :         computationState?.dataTableFieldsByName,
     232            1 :         computationState?.rowNumberColumnName,
     233            1 :         computationState?.tableId,
     234            1 :         computationState?.version,
     235            1 :         computationState?.filterTable,
     236            1 :         computationState?.tasks.filteringTask,
     237            1 :         crossFilterTransforms,
     238            1 :         dispatchComputation,
     239            1 :     ]);
     240              : 
     241            1 :     const requestFilteredColumnAggregation = React.useCallback((columnId: number) => {
     242            1 :         const state = stateRef.current;
     243            1 :         if (state == null) {
     244            0 :             return;
     245              :         }
     246            1 :         const tableAggregation = state.tableAggregation;
     247            1 :         const filterTable = state.filterTable;
     248            1 :         const dataSearchTable = state.dataSearchTable;
     249            1 :         const inputDataFrame = state.dataFrame;
     250            1 :         const columnEntry = state.columnGroups[columnId];
     251            1 :         const unfilteredAggregate = state.columnAggregates[columnId];
     252            1 :         if (
     253            1 :             tableAggregation == null
     254            1 :             || (filterTable == null && dataSearchTable == null)
     255            1 :             || inputDataFrame == null
     256            1 :             || columnEntry == null
     257            1 :             || unfilteredAggregate == null
     258              :         ) {
     259            1 :             return;
     260              :         }
     261              : 
     262            1 :         const currentTask = state.tasks.filteredColumnAggregationTasks[columnId];
     263            1 :         const hasUpToDateRunningTask = currentTask?.progress.status === TaskStatus.TASK_RUNNING
     264            0 :             && currentTask.tableVersion.filterMatches(state.version)
     265            0 :             && (currentTask.dataSearchTable?.requestId ?? null) === (dataSearchTable?.requestId ?? null);
     266            1 :         if (hasUpToDateRunningTask) {
     267            0 :             return;
     268              :         }
     269              : 
     270            1 :         const scheduleKey = `aggregate:${state.tableId}:${columnId}:${state.version.toString()}:${dataSearchTable?.requestId ?? ''}`;
     271            1 :         const releaseSchedule = claimSchedule(dispatchComputation, scheduleKey);
     272            1 :         if (releaseSchedule == null) {
     273            1 :             return;
     274              :         }
     275              : 
     276            1 :         const task: WithFilter<ColumnAggregationTask> = {
     277            1 :             tableId: state.tableId,
     278            1 :             tableVersion: filterTable?.version ?? state.version,
     279            1 :             columnId,
     280            1 :             tableAggregate: tableAggregation,
     281            1 :             columnEntry,
     282            1 :             inputDataFrame,
     283            1 :             filterTable,
     284            1 :             dataSearchTable,
     285            1 :             selectionRowCount: countSelectionRows(filterTable, dataSearchTable),
     286            1 :             unfilteredAggregate,
     287            1 :         };
     288            1 :         void scheduleTask(FILTERED_COLUMN_AGGREGATION_TASK, task, dispatchComputation).finally(releaseSchedule);
     289            1 :     }, [dispatchComputation]);
     290              : 
     291            1 :     return {
     292            1 :         crossFilters,
     293            1 :         histogramFilter,
     294            1 :         mostFrequentValueFilter,
     295            1 :         requestFilteredColumnAggregation,
     296            1 :     };
     297            1 : }
     298              : 
     299              : /// Selection row counts per filter table and data search table.
     300              : /// Weakly keyed by both Arrow tables, so a count never outlives the tables it was computed from.
     301            1 : const selectionRowCounts = new WeakMap<object, WeakMap<object, number>>();
     302              : 
     303            1 : function countSelectionRows(filterTable: FilterTable | null, dataSearchTable: DataSearchTable | null): number {
     304            1 :     if (filterTable == null) return dataSearchTable?.dataTable.numRows ?? 0;
     305            1 :     if (dataSearchTable == null) return filterTable.dataTable.numRows;
     306            1 :     let countsBySearch = selectionRowCounts.get(filterTable.dataTable);
     307            1 :     const cached = countsBySearch?.get(dataSearchTable.dataTable);
     308            1 :     if (cached !== undefined) return cached;
     309            1 :     const searchRows = dataSearchTable.dataTable.getChildAt(0);
     310            1 :     const filterRows = filterTable.dataTable.getChildAt(0);
     311            1 :     if (searchRows == null || filterRows == null) return 0;
     312            1 :     const matches = new Set<number>();
     313            1 :     forEachIntegerValue(searchRows, rowId => matches.add(rowId));
     314            1 :     let count = 0;
     315            1 :     forEachIntegerValue(filterRows, rowId => {
     316            1 :         if (matches.has(rowId)) ++count;
     317            1 :     });
     318            1 :     if (countsBySearch == null) {
     319            1 :         countsBySearch = new WeakMap();
     320            1 :         selectionRowCounts.set(filterTable.dataTable, countsBySearch);
     321            1 :     }
     322            1 :     countsBySearch.set(dataSearchTable.dataTable, count);
     323            1 :     return count;
     324            1 : }
     325              : 
     326            1 : const EMPTY_CROSS_FILTERS = new CrossFilters();
     327            1 : const EMPTY_COLUMN_GROUPS: TableComputationState['columnGroups'] = [];
        

Generated by: LCOV version 2.0-1