refactor(grid): extract column filter workflow

This commit is contained in:
t8y2
2026-09-08 23:46:42 +08:00
parent ecc19fdd30
commit 16eb52c81f
2 changed files with 507 additions and 422 deletions
+70 -422
View File
@@ -112,7 +112,7 @@ import {
isTdengineExistingRowReadonlyColumn,
shouldIncludeSyntheticRowId,
} from "@/lib/table/tableEditing";
import { buildDataGridColumnDistinctValuesSql, buildDataGridConditionalUpdateSql, buildDataGridContextFilterCondition, buildDataGridCountSql, buildHiveTablePropertiesSql, type DataGridContextFilterMode } from "@/lib/dataGrid/dataGridSql";
import { buildDataGridConditionalUpdateSql, buildDataGridContextFilterCondition, buildDataGridCountSql, buildHiveTablePropertiesSql, type DataGridContextFilterMode } from "@/lib/dataGrid/dataGridSql";
import {
buildVisibleTransposeRows,
averageTransposeRecordWidth,
@@ -244,7 +244,6 @@ import type { QueryEditabilityReason } from "@/lib/sql/sqlAnalysis";
import { EDITOR_FONT_FAMILY_CSS_VAR } from "@/lib/editor/editorThemes";
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
import {
appendColumnValueFilterCondition,
buildColumnValueFilterCondition,
buildColumnValuesFilterCondition,
combineWhereInputs,
@@ -255,8 +254,6 @@ import {
filterModeUsesRange,
parseFilterValue,
parseFilterValues,
removeColumnValueFilterCondition,
replaceColumnValueFilterCondition,
} from "@/lib/dataGrid/dataGridColumnFilter";
import { normalizeResultPageSize, resultPageSizeMenuOptions } from "@/lib/dataGrid/paginationPageSize";
import { dataGridPageSizeSettingsPatch, preferredDataGridPageSize, resolveDataGridPageSizePreference, type DataGridPageSizePreference } from "@/lib/dataGrid/dataGridPageSizePreference";
@@ -353,7 +350,7 @@ import { reserveDataGridHeaderLine } from "@/lib/dataGrid/dataGridHeaderLayout";
import { buildColumnIndexMap, columnIndexColorClass, columnIndexNameKey, type ColumnIndexKind } from "@/lib/dataGrid/dataGridColumnIndexIcon";
import { supportsTableStructureEditing } from "@/lib/database/databaseCapabilities";
import { rememberDataGridConditionHistory } from "@/lib/dataGrid/dataGridConditionHistory";
import { buildDataGridLocalFilterOptions, dataGridLocalFilterKey, dataGridLocalFilterLabel, restoreDataGridLocalColumnFilters, rowMatchesDataGridLocalColumnFilters, serializeDataGridLocalColumnFilters, type DataGridLocalFilterOption } from "@/lib/dataGrid/dataGridLocalColumnFilterState";
import { restoreDataGridLocalColumnFilters, type DataGridLocalFilterOption } from "@/lib/dataGrid/dataGridLocalColumnFilterState";
import { effectiveDatabaseTypeForConnection, gaussdbCountQueryDopHint } from "@/lib/database/jdbcDialect";
import { mongoCollectionSupportsIndexes, supportsMongoIndexMutations } from "@/lib/mongo/mongoCapabilities";
import { isProtectedMongoIndex, mongoDropAllIndexesPreview, mongoDropIndexFailureCount, mongoDropIndexPreview } from "@/lib/sidebar/mongoCollectionMutation";
@@ -366,6 +363,7 @@ import { queryTimeoutSecsForConnection } from "@/lib/sql/queryTimeout";
import { SearchableSelect } from "@/components/ui/searchable-select";
import { useDataGridColumnFormatter } from "@/composables/useDataGridColumnFormatter";
import { useDataGridTableMetadataLoaders } from "@/composables/useDataGridTableMetadataLoaders";
import { DATA_GRID_SERVER_COLUMN_FILTER_LIMIT, useDataGridColumnFilters } from "@/composables/useDataGridColumnFilters";
const SqlPreviewPanel = defineAsyncComponent(() => import("@/components/editor/SqlPreviewPanel.vue"));
const ImagePreviewDialog = defineAsyncComponent(() => import("@/components/grid/ImagePreviewDialog.vue"));
@@ -1084,18 +1082,15 @@ let localFilterResizeStartWidth = LOCAL_FILTER_POPOVER_DEFAULT_WIDTH;
let localFilterResizeStartOffsetX = 0;
let localFilterResizeStartLeft = 0;
let localFilterResizeStartRight = 0;
const SERVER_COLUMN_FILTER_LIMIT = 1000;
const SERVER_COLUMN_FILTER_DEBOUNCE_MS = 300;
const serverFilterLoading = ref(false);
const serverFilterError = ref("");
const serverFilterOptions = ref<LocalFilterOption[]>([]);
const serverFilterLimited = ref(false);
const serverFilterValueByKey = ref<Map<string, CellValue>>(new Map());
const serverColumnFilters = ref<Record<number, DataGridCachedServerColumnFilter>>({});
let serverFilterRequestId = 0;
let serverFilterSearchTimer: ReturnType<typeof window.setTimeout> | undefined;
watch(localColumnFilters, (filters) => emit("local-column-filters-change", serializeDataGridLocalColumnFilters(filters)), { deep: true });
let getGridNewRows: () => readonly (readonly CellValue[])[] = () => [];
let getGridRowData: (row: CellValue[], sourceIndex: number) => readonly CellValue[] = (row) => row;
let resetLocalFilterGridScroll: () => void = () => {};
const allFilterModeOptions: Array<{ value: FilterMode; labelKey: string }> = [
{ value: "equals", labelKey: "grid.filterBuilderEquals" },
{ value: "not-equals", labelKey: "grid.filterBuilderNotEquals" },
@@ -1212,234 +1207,67 @@ function formatForeignKeyCellDisplay(value: CellValue, columnIndex: number): str
return formatForeignKeyDisplayValue(value, state?.labels, state?.keyDataType);
}
function localFilterKey(value: CellValue): string {
return dataGridLocalFilterKey(value);
}
function localFilterLabel(value: CellValue, columnIndex: number): string {
return dataGridLocalFilterLabel(value, columnIndex, formatCellCached);
}
function localFilterActive(colIdx: number): boolean {
return !!localColumnFilters.value[colIdx]?.size || !!serverColumnFilters.value[colIdx];
}
const localFilterCount = computed(() => Object.values(localColumnFilters.value).filter((values) => values.size).length);
const serverColumnFilterCount = computed(() => Object.keys(serverColumnFilters.value).length);
const hasLocalColumnFilters = computed(() => localFilterCount.value > 0);
const hasServerColumnFilters = computed(() => serverColumnFilterCount.value > 0);
const filterButtonCount = computed(() => structuredFilterCount.value + localFilterCount.value + serverColumnFilterCount.value);
const filterButtonActive = computed(() => hasStructuredFilters.value || hasLocalColumnFilters.value || hasServerColumnFilters.value);
const localFilterSummaries = computed(() =>
[
...Object.entries(localColumnFilters.value)
.filter(([, selected]) => selected.size > 0)
.map(([columnIndexText, selected]) => {
const columnIndex = Number(columnIndexText);
const labelByKey = new Map(buildLocalFilterOptions(columnIndex).map((option) => [option.key, option.label]));
return {
columnIndex,
values: [...selected].map((key) => labelByKey.get(key) ?? key),
};
}),
...Object.entries(serverColumnFilters.value).map(([columnIndexText, filter]) => ({
columnIndex: Number(columnIndexText),
values: filter.labels,
})),
].map(({ columnIndex, values }) => ({
columnIndex,
columnName: props.result.columns[columnIndex] ?? `#${columnIndex + 1}`,
values: values.slice(0, 3),
hiddenValueCount: Math.max(0, values.length - 3),
})),
);
function rowMatchesLocalColumnFilters(data: CellValue[]): boolean {
return rowMatchesDataGridLocalColumnFilters(data, localColumnFilters.value);
}
const localFilteredRows = computed(() => {
const rows = props.result.rows;
const indices: number[] = [];
if (!hasLocalColumnFilters.value) {
for (let i = 0; i < rows.length; i++) indices.push(i);
return indices;
}
for (let i = 0; i < rows.length; i++) {
if (rowMatchesLocalColumnFilters(rowDataWithChanges(rows[i], i))) {
indices.push(i);
}
}
return indices;
const localColumnFilterRuntime = useDataGridColumnFilters({
state: {
localColumnFilters,
localFilterOpenColumn,
localFilterSearch,
localFilterDraft,
serverFilterLoading,
serverFilterError,
serverFilterOptions,
serverFilterLimited,
serverFilterValueByKey,
serverColumnFilters,
},
getResult: () => props.result,
getTableMeta: () => props.tableMeta,
getConnectionId: () => props.connectionId,
getSchema: () => props.schema,
getExecutionDatabase: () => props.executionDatabase ?? props.database ?? "",
resolvedDatabaseType,
canUseWhereSearch,
canUseServerColumnFilter,
structuredFilterCount,
hasStructuredFilters,
whereFilterInput,
getConnectionConfig: () => (props.connectionId ? connectionStore.getConfig(props.connectionId) : undefined),
getIdentifierQuote: () => connectionStore.connectionIdentifierQuote?.(props.connectionId),
getNewRows: () => getGridNewRows(),
getRowData: (row, sourceIndex) => getGridRowData(row, sourceIndex),
formatValue: formatCellCached,
waitForTableMeta,
applyWhereFilter,
resetGridVerticalScroll: () => resetLocalFilterGridScroll(),
onClose: () => {
onLocalFilterResizeEnd();
localFilterPopoverOffsetX.value = 0;
},
emitLocalFiltersChange: (filters) => emit("local-column-filters-change", filters),
});
function buildLocalFilterOptions(columnIndex: number): LocalFilterOption[] {
return buildDataGridLocalFilterOptions({
rows: props.result.rows,
newRows: newRows.value,
columnIndex,
getRowData: rowDataWithChanges,
formatValue: formatCellCached,
});
}
const localFilterAllOptions = computed(() => {
if (localFilterDraft.value?.mode === "server") return serverFilterOptions.value;
const columnIndex = localFilterDraft.value?.columnIndex;
if (columnIndex === undefined) return [];
return buildLocalFilterOptions(columnIndex);
});
const localFilterOptions = computed(() => {
if (localFilterDraft.value?.mode === "server") return serverFilterOptions.value;
const query = localFilterSearch.value.trim().toLowerCase();
return localFilterAllOptions.value.filter((option) => !query || option.label.toLowerCase().includes(query)).slice(0, 500);
});
const localFilterAllVisibleSelected = computed(() => {
const draft = localFilterDraft.value;
if (!draft || localFilterOptions.value.length === 0) return false;
return localFilterOptions.value.every((option) => draft.values.has(option.key));
});
const localFilterTypedValue = computed(() => localFilterSearch.value.trim());
const localFilterDraftIsAllSelected = computed(() => {
const draft = localFilterDraft.value;
if (!draft) return false;
const allKeys = localFilterAllOptions.value.map((option) => option.key);
return allKeys.length > 0 && allKeys.every((key) => draft.values.has(key));
});
const canApplyTypedLocalFilterValue = computed(() => {
const draft = localFilterDraft.value;
const typed = localFilterTypedValue.value;
if (!draft || !typed || !canUseWhereSearch.value) return false;
const normalized = typed.toLowerCase();
return !localFilterAllOptions.value.some((option) => option.label.toLowerCase() === normalized);
});
function openLocalFilter(colIdx: number, requestedMode: LocalFilterMode = "local") {
localFilterSearch.value = "";
localFilterPopoverOffsetX.value = 0;
const mode: LocalFilterMode = requestedMode === "server" && canUseServerColumnFilter.value ? "server" : "local";
const allKeys = mode === "server" ? [] : buildLocalFilterOptions(colIdx).map((option) => option.key);
localFilterDraft.value = {
columnIndex: colIdx,
values: new Set(mode === "server" ? allKeys : (localColumnFilters.value[colIdx] ?? allKeys)),
mode,
touched: false,
};
localFilterOpenColumn.value = colIdx;
if (mode === "server") {
resetServerFilterState();
void loadServerFilterValues(colIdx, "");
} else {
resetServerFilterState();
}
}
function resetServerFilterState() {
serverFilterRequestId++;
if (serverFilterSearchTimer !== undefined) {
window.clearTimeout(serverFilterSearchTimer);
serverFilterSearchTimer = undefined;
}
serverFilterLoading.value = false;
serverFilterError.value = "";
serverFilterOptions.value = [];
serverFilterLimited.value = false;
serverFilterValueByKey.value = new Map();
}
function serverFilterOptionFromRow(row: QueryResult["rows"][number], columnIndex: number): LocalFilterOption {
const value = (row[0] ?? null) as CellValue;
const countValue = Number(row[1]);
const count = Number.isFinite(countValue) ? countValue : null;
return {
key: localFilterKey(value),
label: localFilterLabel(value, columnIndex),
count,
value,
};
}
function serverFilterOptionsFromResult(result: QueryResult, columnIndex: number): LocalFilterOption[] {
const byKey = new Map<string, LocalFilterOption>();
for (const row of result.rows) {
const option = serverFilterOptionFromRow(row, columnIndex);
const current = byKey.get(option.key);
if (current) {
current.count = (current.count ?? 0) + (option.count ?? 0);
} else {
byKey.set(option.key, option);
}
}
return [...byKey.values()];
}
function syncServerFilterDraft(columnIndex: number, options: LocalFilterOption[]) {
const draft = localFilterDraft.value;
if (!draft || draft.mode !== "server" || draft.columnIndex !== columnIndex) return;
if (draft.touched) return;
const activeFilter = serverColumnFilters.value[columnIndex];
localFilterDraft.value = {
...draft,
values: new Set(activeFilter?.keys ?? options.map((option) => option.key)),
};
}
async function loadServerFilterValues(columnIndex: number, searchValue: string) {
if (!canUseServerColumnFilter.value || !props.connectionId) return;
const columnName = props.result.columns[columnIndex];
if (!columnName) return;
const requestId = ++serverFilterRequestId;
serverFilterLoading.value = true;
serverFilterError.value = "";
serverFilterLimited.value = false;
try {
const tableMeta = await waitForTableMeta();
if (!tableMeta) return;
const columnInfo = tableMeta.columns.find((column) => column.name === columnName);
const sql = await buildDataGridColumnDistinctValuesSql({
databaseType: resolvedDatabaseType.value,
driverProfile: props.connectionId ? connectionStore.getConfig(props.connectionId)?.driver_profile : undefined,
identifierQuote: connectionStore.connectionIdentifierQuote?.(props.connectionId),
catalog: tableMeta.catalog,
database: tableMeta.database,
schema: tableMeta.schema,
tableName: tableMeta.tableName,
columnName,
columnInfo,
// Database value enumeration must remain independent from the active filter;
// otherwise reopening the same column can only return its previously selected values.
searchValue: searchValue.trim() || undefined,
limit: SERVER_COLUMN_FILTER_LIMIT,
includeCounts: true,
});
const result = await api.executeQuery(props.connectionId, props.executionDatabase ?? props.database ?? "", sql, tableMeta.schema ?? props.schema, undefined, {
maxRows: SERVER_COLUMN_FILTER_LIMIT,
fetchSize: SERVER_COLUMN_FILTER_LIMIT,
pageSize: SERVER_COLUMN_FILTER_LIMIT,
});
if (requestId !== serverFilterRequestId || localFilterOpenColumn.value !== columnIndex) return;
const options = serverFilterOptionsFromResult(result, columnIndex);
const nextValueByKey = new Map(serverFilterValueByKey.value);
for (const option of options) nextValueByKey.set(option.key, option.value);
serverFilterValueByKey.value = nextValueByKey;
serverFilterOptions.value = options;
serverFilterLimited.value = result.truncated === true || result.rows.length >= SERVER_COLUMN_FILTER_LIMIT;
syncServerFilterDraft(columnIndex, options);
} catch (e: any) {
if (requestId !== serverFilterRequestId) return;
serverFilterOptions.value = [];
serverFilterError.value = String(e?.message || e);
} finally {
if (requestId === serverFilterRequestId) {
serverFilterLoading.value = false;
}
}
}
const {
localFilterActive,
localFilterCount,
serverColumnFilterCount,
hasLocalColumnFilters,
hasServerColumnFilters,
filterButtonCount,
filterButtonActive,
localFilterSummaries,
localFilteredRows,
localFilterAllOptions,
localFilterOptions,
localFilterTypedValue,
canApplyTypedLocalFilterValue,
openLocalFilter,
closeLocalFilter,
toggleLocalFilterValue,
toggleAllLocalFilterOptions,
applyLocalFilter,
applyTypedLocalFilterValue,
clearLocalFilter,
rowMatchesLocalColumnFilters,
} = localColumnFilterRuntime;
function guardHeaderPanelDismiss() {
headerPanelDismissGuardUntil.value = Date.now() + 350;
@@ -1499,15 +1327,6 @@ function handleLocalFilterOpenChange(value: boolean, columnIndex: number) {
}
}
function closeLocalFilter() {
onLocalFilterResizeEnd();
localFilterOpenColumn.value = null;
localFilterDraft.value = null;
localFilterSearch.value = "";
localFilterPopoverOffsetX.value = 0;
resetServerFilterState();
}
function onLocalFilterResizeStart(event: MouseEvent, direction: "left" | "right") {
event.preventDefault();
isResizingLocalFilter.value = true;
@@ -1569,177 +1388,6 @@ function openCompactColumnFormatter(columnIndex: number) {
});
}
function toggleLocalFilterValue(key: string) {
const draft = localFilterDraft.value;
if (!draft) return;
const next = new Set(draft.values);
if (next.has(key)) next.delete(key);
else next.add(key);
localFilterDraft.value = { ...draft, values: next, touched: true };
}
function toggleAllLocalFilterOptions() {
const draft = localFilterDraft.value;
if (!draft) return;
const visibleKeys = localFilterOptions.value.map((option) => option.key);
const next = new Set(draft.values);
if (localFilterAllVisibleSelected.value) {
visibleKeys.forEach((key) => next.delete(key));
} else {
visibleKeys.forEach((key) => next.add(key));
}
localFilterDraft.value = { ...draft, values: next, touched: true };
}
async function applyLocalFilter() {
const draft = localFilterDraft.value;
if (!draft) return;
if (draft.mode === "server") {
await applyServerColumnFilter(draft);
return;
}
if (canApplyTypedLocalFilterValue.value && localFilterDraftIsAllSelected.value && localFilterOptions.value.length === 0) {
await applyTypedLocalFilterValue();
return;
}
const allKeys = new Set(localFilterAllOptions.value.map((option) => option.key));
const next = { ...localColumnFilters.value };
let selected = draft.values;
if (localFilterSearch.value.trim()) {
const visibleKeys = new Set(localFilterOptions.value.map((o) => o.key));
selected = new Set([...draft.values].filter((k) => visibleKeys.has(k)));
}
if (selected.size === 0 || selected.size === allKeys.size) {
delete next[draft.columnIndex];
} else {
next[draft.columnIndex] = new Set(selected);
}
localColumnFilters.value = next;
closeLocalFilter();
resetGridVerticalScroll();
}
async function applyServerColumnFilter(draft: LocalColumnFilterDraft) {
if (!draft.touched && !localFilterSearch.value.trim()) {
closeLocalFilter();
return;
}
if (canApplyTypedLocalFilterValue.value && serverFilterOptions.value.length === 0) {
await applyTypedLocalFilterValue();
return;
}
const columnName = props.result.columns[draft.columnIndex];
if (!columnName) return;
const values = [...draft.values].flatMap((key) => {
if (!serverFilterValueByKey.value.has(key)) return [];
return [serverFilterValueByKey.value.get(key)!];
});
if (values.length === 0) {
closeLocalFilter();
return;
}
const condition = await buildColumnValuesFilterCondition({
databaseType: resolvedDatabaseType.value,
identifierQuote: connectionStore.connectionIdentifierQuote?.(props.connectionId),
columnName,
columnInfo: props.tableMeta?.columns.find((column) => column.name === columnName),
values,
});
if (!condition) return;
const next = { ...localColumnFilters.value };
delete next[draft.columnIndex];
localColumnFilters.value = next;
const previousCondition = serverColumnFilters.value[draft.columnIndex]?.condition;
whereFilterInput.value = replaceColumnValueFilterCondition(whereFilterInput.value, previousCondition, condition);
serverColumnFilters.value = {
...serverColumnFilters.value,
[draft.columnIndex]: {
condition,
keys: [...draft.values],
labels: values.map((value) => localFilterLabel(value, draft.columnIndex)),
},
};
closeLocalFilter();
await applyWhereFilter();
}
async function applyTypedLocalFilterValue() {
const draft = localFilterDraft.value;
if (!draft) return;
const columnName = props.result.columns[draft.columnIndex];
if (!columnName) return;
const columnInfo = props.tableMeta?.columns.find((column) => column.name === columnName);
const condition = await buildColumnValueFilterCondition({
databaseType: resolvedDatabaseType.value,
identifierQuote: connectionStore.connectionIdentifierQuote?.(props.connectionId),
columnName,
columnInfo,
rawValue: localFilterTypedValue.value,
});
if (!condition) return;
const next = { ...localColumnFilters.value };
delete next[draft.columnIndex];
localColumnFilters.value = next;
if (draft.mode === "server") {
const previousCondition = serverColumnFilters.value[draft.columnIndex]?.condition;
const rawValue = localFilterTypedValue.value.trim();
const value = (/^null$/i.test(rawValue) ? null : parseFilterValue(rawValue, columnInfo, resolvedDatabaseType.value)) as CellValue;
whereFilterInput.value = replaceColumnValueFilterCondition(whereFilterInput.value, previousCondition, condition);
serverColumnFilters.value = {
...serverColumnFilters.value,
[draft.columnIndex]: {
condition,
keys: [localFilterKey(value)],
labels: [localFilterLabel(value, draft.columnIndex)],
},
};
} else {
whereFilterInput.value = appendColumnValueFilterCondition(whereFilterInput.value, condition);
}
closeLocalFilter();
await applyWhereFilter();
}
function clearLocalFilter(colIdx?: number, applyServerWhereFilter = true) {
let removedServerFilter = false;
if (colIdx === undefined) {
localColumnFilters.value = {};
let nextWhereInput = whereFilterInput.value;
for (const filter of Object.values(serverColumnFilters.value)) {
nextWhereInput = removeColumnValueFilterCondition(nextWhereInput, filter.condition);
}
removedServerFilter = Object.keys(serverColumnFilters.value).length > 0;
serverColumnFilters.value = {};
whereFilterInput.value = nextWhereInput;
} else {
const next = { ...localColumnFilters.value };
delete next[colIdx];
localColumnFilters.value = next;
const serverFilter = serverColumnFilters.value[colIdx];
if (serverFilter) {
removedServerFilter = true;
const nextServerFilters = { ...serverColumnFilters.value };
delete nextServerFilters[colIdx];
serverColumnFilters.value = nextServerFilters;
whereFilterInput.value = removeColumnValueFilterCondition(whereFilterInput.value, serverFilter.condition);
}
}
closeLocalFilter();
resetGridVerticalScroll();
if (removedServerFilter && applyServerWhereFilter && canUseWhereSearch.value) void applyWhereFilter();
}
watch(localFilterSearch, (value) => {
const draft = localFilterDraft.value;
if (!draft || draft.mode !== "server" || localFilterOpenColumn.value !== draft.columnIndex) return;
if (serverFilterSearchTimer !== undefined) {
window.clearTimeout(serverFilterSearchTimer);
}
serverFilterSearchTimer = window.setTimeout(() => {
void loadServerFilterValues(draft.columnIndex, value);
}, SERVER_COLUMN_FILTER_DEBOUNCE_MS);
});
function defaultStructuredFilterRule(): StructuredFilterRule {
return filterBuilder.defaultRule();
}
@@ -3926,6 +3574,9 @@ const {
isPreviewLoading,
previewChanges,
} = editor;
getGridNewRows = () => newRows.value;
getGridRowData = rowDataWithChanges;
resetLocalFilterGridScroll = resetGridVerticalScroll;
const pendingQuickEntryDraftCellFocus = ref<{
rowId: number;
col: number;
@@ -11409,9 +11060,6 @@ onUnmounted(() => {
onMongoJsonPreviewResizeEnd();
finishCellSelection();
clearTimeout(highlightedColumnTimer);
if (serverFilterSearchTimer !== undefined) {
window.clearTimeout(serverFilterSearchTimer);
}
stopLoadingElapsedTimer();
});
@@ -13046,7 +12694,7 @@ function openGridSnapshot() {
:server-loading="serverFilterLoading"
:server-error="serverFilterError"
:server-limited="serverFilterLimited"
:server-value-limit="SERVER_COLUMN_FILTER_LIMIT"
:server-value-limit="DATA_GRID_SERVER_COLUMN_FILTER_LIMIT"
@update:open="(value) => handleLocalFilterOpenChange(value, col.actualColIdx)"
@update:search="localFilterSearch = $event"
@resize-start="onLocalFilterResizeStart"
@@ -0,0 +1,437 @@
import { computed, onUnmounted, watch, type ComputedRef, type Ref } from "vue";
import * as api from "@/lib/backend/api";
import { buildColumnValueFilterCondition, buildColumnValuesFilterCondition, parseFilterValue, removeColumnValueFilterCondition, replaceColumnValueFilterCondition, appendColumnValueFilterCondition } from "@/lib/dataGrid/dataGridColumnFilter";
import { buildDataGridColumnDistinctValuesSql } from "@/lib/dataGrid/dataGridSql";
import { buildDataGridLocalFilterOptions, dataGridLocalFilterKey, dataGridLocalFilterLabel, rowMatchesDataGridLocalColumnFilters, type DataGridLocalFilterOption } from "@/lib/dataGrid/dataGridLocalColumnFilterState";
import type { DataGridCachedServerColumnFilter } from "@/lib/dataGrid/dataGridFilterBuilderPersistence";
import type { CellValue } from "@/lib/dataGrid/cellValue";
import type { ColumnInfo, DatabaseType, QueryResult } from "@/types/database";
export type DataGridLocalFilterMode = "local" | "server";
export type DataGridLocalFilterDraft = {
columnIndex: number;
values: Set<string>;
mode: DataGridLocalFilterMode;
touched: boolean;
};
export interface DataGridColumnFilterState {
localColumnFilters: Ref<Record<number, Set<string>>>;
localFilterOpenColumn: Ref<number | null>;
localFilterSearch: Ref<string>;
localFilterDraft: Ref<DataGridLocalFilterDraft | null>;
serverFilterLoading: Ref<boolean>;
serverFilterError: Ref<string>;
serverFilterOptions: Ref<DataGridLocalFilterOption[]>;
serverFilterLimited: Ref<boolean>;
serverFilterValueByKey: Ref<Map<string, CellValue>>;
serverColumnFilters: Ref<Record<number, DataGridCachedServerColumnFilter>>;
}
interface TableMetadata {
catalog?: string;
database?: string;
schema?: string;
tableName: string;
columns: ColumnInfo[];
}
interface ConnectionConfig {
driver_profile?: string;
}
export interface UseDataGridColumnFiltersOptions {
state: DataGridColumnFilterState;
getResult: () => QueryResult;
getTableMeta: () => TableMetadata | undefined;
getConnectionId: () => string | undefined;
getSchema: () => string | undefined;
getExecutionDatabase: () => string;
resolvedDatabaseType: ComputedRef<DatabaseType | undefined>;
canUseWhereSearch: ComputedRef<boolean>;
canUseServerColumnFilter: ComputedRef<boolean>;
structuredFilterCount: ComputedRef<number>;
hasStructuredFilters: ComputedRef<boolean>;
whereFilterInput: Ref<string>;
getConnectionConfig: () => ConnectionConfig | undefined;
getIdentifierQuote: () => string | undefined;
getNewRows: () => readonly (readonly CellValue[])[];
getRowData: (row: CellValue[], sourceIndex: number) => readonly CellValue[];
formatValue: (value: CellValue, columnIndex: number) => string;
waitForTableMeta: () => Promise<TableMetadata | null>;
applyWhereFilter: () => Promise<void>;
resetGridVerticalScroll: () => void;
onClose?: () => void;
emitLocalFiltersChange: (filters: Record<string, string[]>) => void;
}
export const DATA_GRID_SERVER_COLUMN_FILTER_LIMIT = 1000;
const SERVER_COLUMN_FILTER_DEBOUNCE_MS = 300;
export function useDataGridColumnFilters(options: UseDataGridColumnFiltersOptions) {
const { state } = options;
let serverFilterRequestId = 0;
let serverFilterSearchTimer: ReturnType<typeof window.setTimeout> | undefined;
watch(
state.localColumnFilters,
(filters) => {
options.emitLocalFiltersChange(Object.fromEntries(Object.entries(filters).flatMap(([columnIndex, values]) => (values.size > 0 ? [[columnIndex, [...values]]] : []))));
},
{ deep: true },
);
function localFilterLabel(value: CellValue, columnIndex: number): string {
return dataGridLocalFilterLabel(value, columnIndex, options.formatValue);
}
function buildLocalFilterOptions(columnIndex: number): DataGridLocalFilterOption[] {
const result = options.getResult();
return buildDataGridLocalFilterOptions({
rows: result.rows,
newRows: options.getNewRows(),
columnIndex,
getRowData: options.getRowData,
formatValue: options.formatValue,
});
}
const localFilterActive = (columnIndex: number) => !!state.localColumnFilters.value[columnIndex]?.size || !!state.serverColumnFilters.value[columnIndex];
const localFilterCount = computed(() => Object.values(state.localColumnFilters.value).filter((values) => values.size).length);
const serverColumnFilterCount = computed(() => Object.keys(state.serverColumnFilters.value).length);
const hasLocalColumnFilters = computed(() => localFilterCount.value > 0);
const hasServerColumnFilters = computed(() => serverColumnFilterCount.value > 0);
const filterButtonCount = computed(() => options.structuredFilterCount.value + localFilterCount.value + serverColumnFilterCount.value);
const filterButtonActive = computed(() => options.hasStructuredFilters.value || hasLocalColumnFilters.value || hasServerColumnFilters.value);
const localFilterSummaries = computed(() =>
[
...Object.entries(state.localColumnFilters.value)
.filter(([, selected]) => selected.size > 0)
.map(([columnIndexText, selected]) => {
const columnIndex = Number(columnIndexText);
const labelByKey = new Map(buildLocalFilterOptions(columnIndex).map((option) => [option.key, option.label]));
return { columnIndex, values: [...selected].map((key) => labelByKey.get(key) ?? key) };
}),
...Object.entries(state.serverColumnFilters.value).map(([columnIndexText, filter]) => ({ columnIndex: Number(columnIndexText), values: filter.labels })),
].map(({ columnIndex, values }) => ({
columnIndex,
columnName: options.getResult().columns[columnIndex] ?? `#${columnIndex + 1}`,
values: values.slice(0, 3),
hiddenValueCount: Math.max(0, values.length - 3),
})),
);
const localFilteredRows = computed(() => {
const rows = options.getResult().rows;
if (!hasLocalColumnFilters.value) return rows.map((_, index) => index);
return rows.flatMap((row, index) => (rowMatchesDataGridLocalColumnFilters(options.getRowData(row, index), state.localColumnFilters.value) ? [index] : []));
});
const rowMatchesLocalColumnFilters = (data: CellValue[]) => rowMatchesDataGridLocalColumnFilters(data, state.localColumnFilters.value);
const localFilterAllOptions = computed(() => {
if (state.localFilterDraft.value?.mode === "server") return state.serverFilterOptions.value;
const columnIndex = state.localFilterDraft.value?.columnIndex;
return columnIndex === undefined ? [] : buildLocalFilterOptions(columnIndex);
});
const localFilterOptions = computed(() => {
if (state.localFilterDraft.value?.mode === "server") return state.serverFilterOptions.value;
const query = state.localFilterSearch.value.trim().toLowerCase();
return localFilterAllOptions.value.filter((option) => !query || option.label.toLowerCase().includes(query)).slice(0, 500);
});
const localFilterTypedValue = computed(() => state.localFilterSearch.value.trim());
const localFilterDraftIsAllSelected = computed(() => {
const draft = state.localFilterDraft.value;
const allKeys = localFilterAllOptions.value.map((option) => option.key);
return !!draft && allKeys.length > 0 && allKeys.every((key) => draft.values.has(key));
});
const localFilterAllVisibleSelected = computed(() => {
const draft = state.localFilterDraft.value;
return !!draft && localFilterOptions.value.length > 0 && localFilterOptions.value.every((option) => draft.values.has(option.key));
});
const canApplyTypedLocalFilterValue = computed(() => {
const typed = localFilterTypedValue.value;
if (!state.localFilterDraft.value || !typed || !options.canUseWhereSearch.value) return false;
const normalized = typed.toLowerCase();
return !localFilterAllOptions.value.some((option) => option.label.toLowerCase() === normalized);
});
function resetServerFilterState() {
serverFilterRequestId++;
if (serverFilterSearchTimer !== undefined) {
window.clearTimeout(serverFilterSearchTimer);
serverFilterSearchTimer = undefined;
}
state.serverFilterLoading.value = false;
state.serverFilterError.value = "";
state.serverFilterOptions.value = [];
state.serverFilterLimited.value = false;
state.serverFilterValueByKey.value = new Map();
}
function openLocalFilter(columnIndex: number, requestedMode: DataGridLocalFilterMode = "local") {
state.localFilterSearch.value = "";
const mode = requestedMode === "server" && options.canUseServerColumnFilter.value ? "server" : "local";
const allKeys = mode === "server" ? [] : buildLocalFilterOptions(columnIndex).map((option) => option.key);
state.localFilterDraft.value = {
columnIndex,
values: new Set(mode === "server" ? allKeys : (state.localColumnFilters.value[columnIndex] ?? allKeys)),
mode,
touched: false,
};
state.localFilterOpenColumn.value = columnIndex;
resetServerFilterState();
if (mode === "server") void loadServerFilterValues(columnIndex, "");
}
function closeLocalFilter() {
options.onClose?.();
state.localFilterOpenColumn.value = null;
state.localFilterDraft.value = null;
state.localFilterSearch.value = "";
resetServerFilterState();
}
function serverFilterOptionFromRow(row: QueryResult["rows"][number], columnIndex: number): DataGridLocalFilterOption {
const value = (row[0] ?? null) as CellValue;
const countValue = Number(row[1]);
return { key: dataGridLocalFilterKey(value), label: localFilterLabel(value, columnIndex), count: Number.isFinite(countValue) ? countValue : null, value };
}
function serverFilterOptionsFromResult(result: QueryResult, columnIndex: number): DataGridLocalFilterOption[] {
const byKey = new Map<string, DataGridLocalFilterOption>();
for (const row of result.rows) {
const option = serverFilterOptionFromRow(row, columnIndex);
const current = byKey.get(option.key);
if (current) current.count = (current.count ?? 0) + (option.count ?? 0);
else byKey.set(option.key, option);
}
return [...byKey.values()];
}
function syncServerFilterDraft(columnIndex: number, filterOptions: DataGridLocalFilterOption[]) {
const draft = state.localFilterDraft.value;
if (!draft || draft.mode !== "server" || draft.columnIndex !== columnIndex || draft.touched) return;
const activeFilter = state.serverColumnFilters.value[columnIndex];
state.localFilterDraft.value = { ...draft, values: new Set(activeFilter?.keys ?? filterOptions.map((option) => option.key)) };
}
async function loadServerFilterValues(columnIndex: number, searchValue: string) {
const connectionId = options.getConnectionId();
if (!options.canUseServerColumnFilter.value || !connectionId) return;
const columnName = options.getResult().columns[columnIndex];
if (!columnName) return;
const requestId = ++serverFilterRequestId;
state.serverFilterLoading.value = true;
state.serverFilterError.value = "";
state.serverFilterLimited.value = false;
try {
const tableMeta = await options.waitForTableMeta();
if (!tableMeta) return;
const columnInfo = tableMeta.columns.find((column) => column.name === columnName);
const sql = await buildDataGridColumnDistinctValuesSql({
databaseType: options.resolvedDatabaseType.value,
driverProfile: options.getConnectionConfig()?.driver_profile,
identifierQuote: options.getIdentifierQuote(),
catalog: tableMeta.catalog,
database: tableMeta.database,
schema: tableMeta.schema,
tableName: tableMeta.tableName,
columnName,
columnInfo,
searchValue: searchValue.trim() || undefined,
limit: DATA_GRID_SERVER_COLUMN_FILTER_LIMIT,
includeCounts: true,
});
const result = await api.executeQuery(connectionId, options.getExecutionDatabase(), sql, tableMeta.schema ?? options.getSchema(), undefined, {
maxRows: DATA_GRID_SERVER_COLUMN_FILTER_LIMIT,
fetchSize: DATA_GRID_SERVER_COLUMN_FILTER_LIMIT,
pageSize: DATA_GRID_SERVER_COLUMN_FILTER_LIMIT,
});
if (requestId !== serverFilterRequestId || state.localFilterOpenColumn.value !== columnIndex) return;
const filterOptions = serverFilterOptionsFromResult(result, columnIndex);
const nextValueByKey = new Map(state.serverFilterValueByKey.value);
for (const option of filterOptions) nextValueByKey.set(option.key, option.value);
state.serverFilterValueByKey.value = nextValueByKey;
state.serverFilterOptions.value = filterOptions;
state.serverFilterLimited.value = result.truncated === true || result.rows.length >= DATA_GRID_SERVER_COLUMN_FILTER_LIMIT;
syncServerFilterDraft(columnIndex, filterOptions);
} catch (error: any) {
if (requestId !== serverFilterRequestId) return;
state.serverFilterOptions.value = [];
state.serverFilterError.value = String(error?.message || error);
} finally {
if (requestId === serverFilterRequestId) state.serverFilterLoading.value = false;
}
}
watch(state.localFilterSearch, (value) => {
const draft = state.localFilterDraft.value;
if (!draft || draft.mode !== "server" || state.localFilterOpenColumn.value !== draft.columnIndex) return;
if (serverFilterSearchTimer !== undefined) window.clearTimeout(serverFilterSearchTimer);
serverFilterSearchTimer = window.setTimeout(() => void loadServerFilterValues(draft.columnIndex, value), SERVER_COLUMN_FILTER_DEBOUNCE_MS);
});
onUnmounted(resetServerFilterState);
function toggleLocalFilterValue(key: string) {
const draft = state.localFilterDraft.value;
if (!draft) return;
const values = new Set(draft.values);
if (values.has(key)) values.delete(key);
else values.add(key);
state.localFilterDraft.value = { ...draft, values, touched: true };
}
function toggleAllLocalFilterOptions() {
const draft = state.localFilterDraft.value;
if (!draft) return;
const visibleKeys = localFilterOptions.value.map((option) => option.key);
const values = new Set(draft.values);
if (localFilterAllVisibleSelected.value) visibleKeys.forEach((key) => values.delete(key));
else visibleKeys.forEach((key) => values.add(key));
state.localFilterDraft.value = { ...draft, values, touched: true };
}
async function applyLocalFilter() {
const draft = state.localFilterDraft.value;
if (!draft) return;
if (draft.mode === "server") {
await applyServerColumnFilter(draft);
return;
}
if (canApplyTypedLocalFilterValue.value && localFilterDraftIsAllSelected.value && localFilterOptions.value.length === 0) {
await applyTypedLocalFilterValue();
return;
}
const allKeys = new Set(localFilterAllOptions.value.map((option) => option.key));
const selected = state.localFilterSearch.value.trim() ? new Set([...draft.values].filter((key) => localFilterOptions.value.some((option) => option.key === key))) : draft.values;
const next = { ...state.localColumnFilters.value };
if (selected.size === 0 || selected.size === allKeys.size) delete next[draft.columnIndex];
else next[draft.columnIndex] = new Set(selected);
state.localColumnFilters.value = next;
closeLocalFilter();
options.resetGridVerticalScroll();
}
async function applyServerColumnFilter(draft: DataGridLocalFilterDraft) {
if (!draft.touched && !state.localFilterSearch.value.trim()) {
closeLocalFilter();
return;
}
if (canApplyTypedLocalFilterValue.value && state.serverFilterOptions.value.length === 0) {
await applyTypedLocalFilterValue();
return;
}
const columnName = options.getResult().columns[draft.columnIndex];
if (!columnName) return;
const values = [...draft.values].flatMap((key) => (state.serverFilterValueByKey.value.has(key) ? [state.serverFilterValueByKey.value.get(key)!] : []));
if (values.length === 0) {
closeLocalFilter();
return;
}
const condition = await buildColumnValuesFilterCondition({
databaseType: options.resolvedDatabaseType.value,
identifierQuote: options.getIdentifierQuote(),
columnName,
columnInfo: options.getTableMeta()?.columns.find((column) => column.name === columnName),
values,
});
if (!condition) return;
const next = { ...state.localColumnFilters.value };
delete next[draft.columnIndex];
state.localColumnFilters.value = next;
const previousCondition = state.serverColumnFilters.value[draft.columnIndex]?.condition;
options.whereFilterInput.value = replaceColumnValueFilterCondition(options.whereFilterInput.value, previousCondition, condition);
state.serverColumnFilters.value = {
...state.serverColumnFilters.value,
[draft.columnIndex]: { condition, keys: [...draft.values], labels: values.map((value) => localFilterLabel(value, draft.columnIndex)) },
};
closeLocalFilter();
await options.applyWhereFilter();
}
async function applyTypedLocalFilterValue() {
const draft = state.localFilterDraft.value;
if (!draft) return;
const columnName = options.getResult().columns[draft.columnIndex];
if (!columnName) return;
const columnInfo = options.getTableMeta()?.columns.find((column) => column.name === columnName);
const condition = await buildColumnValueFilterCondition({
databaseType: options.resolvedDatabaseType.value,
identifierQuote: options.getIdentifierQuote(),
columnName,
columnInfo,
rawValue: localFilterTypedValue.value,
});
if (!condition) return;
const next = { ...state.localColumnFilters.value };
delete next[draft.columnIndex];
state.localColumnFilters.value = next;
if (draft.mode === "server") {
const previousCondition = state.serverColumnFilters.value[draft.columnIndex]?.condition;
const rawValue = localFilterTypedValue.value.trim();
const value = (/^null$/i.test(rawValue) ? null : parseFilterValue(rawValue, columnInfo, options.resolvedDatabaseType.value)) as CellValue;
options.whereFilterInput.value = replaceColumnValueFilterCondition(options.whereFilterInput.value, previousCondition, condition);
state.serverColumnFilters.value = {
...state.serverColumnFilters.value,
[draft.columnIndex]: { condition, keys: [dataGridLocalFilterKey(value)], labels: [localFilterLabel(value, draft.columnIndex)] },
};
} else {
options.whereFilterInput.value = appendColumnValueFilterCondition(options.whereFilterInput.value, condition);
}
closeLocalFilter();
await options.applyWhereFilter();
}
function clearLocalFilter(columnIndex?: number, applyServerWhereFilter = true) {
let removedServerFilter = false;
if (columnIndex === undefined) {
state.localColumnFilters.value = {};
let nextWhereInput = options.whereFilterInput.value;
for (const filter of Object.values(state.serverColumnFilters.value)) nextWhereInput = removeColumnValueFilterCondition(nextWhereInput, filter.condition);
removedServerFilter = Object.keys(state.serverColumnFilters.value).length > 0;
state.serverColumnFilters.value = {};
options.whereFilterInput.value = nextWhereInput;
} else {
const next = { ...state.localColumnFilters.value };
delete next[columnIndex];
state.localColumnFilters.value = next;
const serverFilter = state.serverColumnFilters.value[columnIndex];
if (serverFilter) {
removedServerFilter = true;
const nextServerFilters = { ...state.serverColumnFilters.value };
delete nextServerFilters[columnIndex];
state.serverColumnFilters.value = nextServerFilters;
options.whereFilterInput.value = removeColumnValueFilterCondition(options.whereFilterInput.value, serverFilter.condition);
}
}
closeLocalFilter();
options.resetGridVerticalScroll();
if (removedServerFilter && applyServerWhereFilter && options.canUseWhereSearch.value) void options.applyWhereFilter();
}
return {
localFilterActive,
localFilterCount,
serverColumnFilterCount,
hasLocalColumnFilters,
hasServerColumnFilters,
filterButtonCount,
filterButtonActive,
localFilterSummaries,
localFilteredRows,
rowMatchesLocalColumnFilters,
localFilterAllOptions,
localFilterOptions,
localFilterTypedValue,
canApplyTypedLocalFilterValue,
openLocalFilter,
closeLocalFilter,
toggleLocalFilterValue,
toggleAllLocalFilterOptions,
applyLocalFilter,
applyTypedLocalFilterValue,
clearLocalFilter,
};
}