diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 0ba13b7217..e8d64eb0d6 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -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([]); const serverFilterLimited = ref(false); const serverFilterValueByKey = ref>(new Map()); const serverColumnFilters = ref>({}); -let serverFilterRequestId = 0; -let serverFilterSearchTimer: ReturnType | 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(); - 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" diff --git a/apps/desktop/src/composables/useDataGridColumnFilters.ts b/apps/desktop/src/composables/useDataGridColumnFilters.ts new file mode 100644 index 0000000000..2e0d498f04 --- /dev/null +++ b/apps/desktop/src/composables/useDataGridColumnFilters.ts @@ -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; + mode: DataGridLocalFilterMode; + touched: boolean; +}; + +export interface DataGridColumnFilterState { + localColumnFilters: Ref>>; + localFilterOpenColumn: Ref; + localFilterSearch: Ref; + localFilterDraft: Ref; + serverFilterLoading: Ref; + serverFilterError: Ref; + serverFilterOptions: Ref; + serverFilterLimited: Ref; + serverFilterValueByKey: Ref>; + serverColumnFilters: Ref>; +} + +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; + canUseWhereSearch: ComputedRef; + canUseServerColumnFilter: ComputedRef; + structuredFilterCount: ComputedRef; + hasStructuredFilters: ComputedRef; + whereFilterInput: Ref; + 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; + applyWhereFilter: () => Promise; + resetGridVerticalScroll: () => void; + onClose?: () => void; + emitLocalFiltersChange: (filters: Record) => 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 | 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(); + 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, + }; +}