From 62bf456261015a9d25e7e23edbe8bea8abf507c4 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Tue, 14 Jul 2026 22:35:06 +0800 Subject: [PATCH] fix(grid): replace repeated database value filters --- apps/desktop/src/components/grid/DataGrid.vue | 115 ++++++++++++---- .../src/lib/dataGrid/dataGridColumnFilter.ts | 123 ++++++++++++++++++ .../app-tests/dataGridColumnFilter.test.ts | 29 ++++- 3 files changed, 240 insertions(+), 27 deletions(-) diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 38e578b7d0..03ff25b1be 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -8,11 +8,17 @@ type CachedStructuredFilterRule = { conjunction: "AND" | "OR"; disabled?: boolean; }; +type CachedServerColumnFilter = { + condition: string; + keys: string[]; + labels: string[]; +}; type StructuredFilterCacheState = { scopeKey: string; manualWhereInput: string; rules: CachedStructuredFilterRule[]; appliedWhereInput: string; + serverColumnFilters?: Record; }; const structuredFilterStateCache = new Map(); @@ -179,6 +185,8 @@ import { filterModeUsesRange, parseFilterValue, parseFilterValues, + removeColumnValueFilterCondition, + replaceColumnValueFilterCondition, } from "@/lib/dataGrid/dataGridColumnFilter"; import { clampSearchSplitWidth } from "@/lib/dataGrid/dataGridSearchSplit"; import { MAX_RESULT_PAGE_SIZE, MIN_RESULT_PAGE_SIZE, normalizeResultPageSize, resultPageSizeMenuOptions } from "@/lib/dataGrid/paginationPageSize"; @@ -1172,6 +1180,7 @@ 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; const filterBuilderOpen = ref(false); @@ -1234,27 +1243,34 @@ function localFilterLabel(value: CellValue, columnIndex: number): string { } function localFilterActive(colIdx: number): boolean { - return !!localColumnFilters.value[colIdx]?.size; + 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 filterButtonCount = computed(() => structuredFilterCount.value + localFilterCount.value); -const filterButtonActive = computed(() => hasStructuredFilters.value || hasLocalColumnFilters.value); +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])); - const values = [...selected].map((key) => labelByKey.get(key) ?? key); - return { - columnIndex, - columnName: props.result.columns[columnIndex] ?? `#${columnIndex + 1}`, - values: values.slice(0, 3), - hiddenValueCount: Math.max(0, values.length - 3), - }; - }), + [ + ...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 { @@ -1402,9 +1418,10 @@ 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(options.map((option) => option.key)), + values: new Set(activeFilter?.keys ?? options.map((option) => option.key)), }; } @@ -1428,7 +1445,8 @@ async function loadServerFilterValues(columnIndex: number, searchValue: string) tableName: tableMeta.tableName, columnName, columnInfo, - whereInput: currentWhereInput(), + // 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, @@ -1766,7 +1784,16 @@ async function applyServerColumnFilter(draft: LocalColumnFilterDraft) { const next = { ...localColumnFilters.value }; delete next[draft.columnIndex]; localColumnFilters.value = next; - whereFilterInput.value = appendColumnValueFilterCondition(whereFilterInput.value, condition); + 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(); } @@ -1776,31 +1803,64 @@ async function applyTypedLocalFilterValue() { 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, columnName, - columnInfo: props.tableMeta?.columns.find((column) => column.name === columnName), + columnInfo, rawValue: localFilterTypedValue.value, }); if (!condition) return; const next = { ...localColumnFilters.value }; delete next[draft.columnIndex]; localColumnFilters.value = next; - whereFilterInput.value = appendColumnValueFilterCondition(whereFilterInput.value, condition); + 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) { +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) => { @@ -1878,6 +1938,7 @@ function persistStructuredFilterState() { manualWhereInput: whereFilterInput.value, rules: cloneStructuredFilterRules(structuredFilterRules.value), appliedWhereInput: appliedStructuredWhereInput.value, + serverColumnFilters: structuredClone(serverColumnFilters.value), }); } @@ -1888,6 +1949,7 @@ function loadStructuredFilterStateForScope() { const scopeKey = structuredFilterScopeKey.value; structuredFilterRules.value = cloneStructuredFilterRules(cached.rules); whereFilterInput.value = cached.manualWhereInput; + serverColumnFilters.value = structuredClone(cached.serverColumnFilters ?? {}); appliedStructuredWhereInput.value = ""; void buildStructuredWhereFromRules(structuredFilterRules.value).then((whereInput) => { if (structuredFilterCacheKey.value !== cacheKey || structuredFilterScopeKey.value !== scopeKey) return; @@ -1897,6 +1959,7 @@ function loadStructuredFilterStateForScope() { return; } appliedStructuredWhereInput.value = ""; + serverColumnFilters.value = {}; structuredFilterRules.value = filterBuilderColumnOptions.value.length > 0 ? [defaultStructuredFilterRule()] : []; persistStructuredFilterState(); } @@ -1965,7 +2028,7 @@ function resetStructuredFilters() { async function clearAllFilters() { whereFilterInput.value = ""; resetStructuredFilters(); - clearLocalFilter(); + clearLocalFilter(undefined, false); if (canUseWhereSearch.value) await applyWhereFilter(); } @@ -2018,7 +2081,7 @@ watch(filterBuilderOpen, (open) => { }); watch( - [structuredFilterRules, appliedStructuredWhereInput], + [structuredFilterRules, appliedStructuredWhereInput, serverColumnFilters], () => { const columns = filterBuilderColumnOptions.value; if (columns.length > 0 && structuredFilterRules.value.some((rule) => !columns.includes(rule.columnName))) { @@ -4725,7 +4788,7 @@ const exportContextCell = computed(() => { const deleteRowDetails = computed(() => (props.tableMeta?.tableName ? t("dangerDialog.deleteRowDetails", { table: props.tableMeta.tableName }) : t("dangerDialog.deleteRowDetailsNoTable"))); const hasVisibleRows = computed(() => displayRowCount.value > 0); -const hasActiveFilter = computed(() => (dataGridSearchMode.value === "filter" && !!deferredClientSearchText.value) || rowStatusFilter.value !== "all" || hasLocalColumnFilters.value); +const hasActiveFilter = computed(() => (dataGridSearchMode.value === "filter" && !!deferredClientSearchText.value) || rowStatusFilter.value !== "all" || hasLocalColumnFilters.value || hasServerColumnFilters.value); const emptyTitle = computed(() => (hasActiveFilter.value ? t("grid.noFilteredRows") : t("grid.noRows"))); const emptyDescription = computed(() => (hasActiveFilter.value ? t("grid.noFilteredRowsDescription") : t("grid.noRowsDescription"))); watch( @@ -9453,7 +9516,7 @@ const gridContextMenuItems = computed(() => { - + diff --git a/apps/desktop/src/lib/dataGrid/dataGridColumnFilter.ts b/apps/desktop/src/lib/dataGrid/dataGridColumnFilter.ts index ebd53c0469..e463caa74d 100644 --- a/apps/desktop/src/lib/dataGrid/dataGridColumnFilter.ts +++ b/apps/desktop/src/lib/dataGrid/dataGridColumnFilter.ts @@ -39,6 +39,129 @@ export function appendColumnValueFilterCondition(whereInput: string | undefined, return existing ? `(${existing}) AND (${condition})` : condition; } +export function removeColumnValueFilterCondition(whereInput: string | undefined, condition: string | undefined): string { + const existing = normalizeWhereInput(whereInput); + const target = normalizeWhereInput(condition); + if (!existing || !target) return existing; + return removeExactCondition(existing, target).value; +} + +export function replaceColumnValueFilterCondition(whereInput: string | undefined, previousCondition: string | undefined, nextCondition: string | undefined): string { + return appendColumnValueFilterCondition(removeColumnValueFilterCondition(whereInput, previousCondition), nextCondition); +} + +type RemovedCondition = { + value: string; + removed: boolean; +}; + +function removeExactCondition(expression: string, target: string): RemovedCondition { + const normalized = normalizeWhereInput(expression); + if (normalized === target) return { value: "", removed: true }; + + // Only decompose top-level AND groups produced by appendColumnValueFilterCondition; + // quote-aware scanning keeps literals and predicates such as BETWEEN intact. + const inner = unwrapOuterParentheses(normalized); + if (inner !== normalized) { + const result = removeExactCondition(inner, target); + if (result.removed) return result; + } + + const split = splitTopLevelAnd(normalized); + if (!split) return { value: normalized, removed: false }; + const left = removeExactCondition(split.left, target); + const right = removeExactCondition(split.right, target); + if (!left.removed && !right.removed) return { value: normalized, removed: false }; + if (!left.value) return { value: unwrapOuterParentheses(right.value), removed: true }; + if (!right.value) return { value: unwrapOuterParentheses(left.value), removed: true }; + return { + value: `(${unwrapOuterParentheses(left.value)}) AND (${unwrapOuterParentheses(right.value)})`, + removed: true, + }; +} + +function unwrapOuterParentheses(expression: string): string { + if (!expression.startsWith("(") || !expression.endsWith(")")) return expression; + let depth = 0; + let quote: "'" | '"' | "`" | null = null; + let bracketQuoted = false; + for (let index = 0; index < expression.length; index += 1) { + const char = expression[index]; + if (bracketQuoted) { + if (char === "]") { + if (expression[index + 1] === "]") index += 1; + else bracketQuoted = false; + } + continue; + } + if (quote) { + if (char === quote) { + if (expression[index + 1] === quote) index += 1; + else quote = null; + } + continue; + } + if (char === "'" || char === '"' || char === "`") { + quote = char; + continue; + } + if (char === "[") { + bracketQuoted = true; + continue; + } + if (char === "(") depth += 1; + else if (char === ")") depth -= 1; + if (depth === 0 && index < expression.length - 1) return expression; + } + return depth === 0 ? expression.slice(1, -1).trim() : expression; +} + +function splitTopLevelAnd(expression: string): { left: string; right: string } | null { + let depth = 0; + let quote: "'" | '"' | "`" | null = null; + let bracketQuoted = false; + for (let index = 0; index <= expression.length - 5; index += 1) { + const char = expression[index]; + if (bracketQuoted) { + if (char === "]") { + if (expression[index + 1] === "]") index += 1; + else bracketQuoted = false; + } + continue; + } + if (quote) { + if (char === quote) { + if (expression[index + 1] === quote) index += 1; + else quote = null; + } + continue; + } + if (char === "'" || char === '"' || char === "`") { + quote = char; + continue; + } + if (char === "[") { + bracketQuoted = true; + continue; + } + if (char === "(") { + depth += 1; + continue; + } + if (char === ")") { + depth -= 1; + continue; + } + if (depth === 0 && expression.slice(index, index + 5).toUpperCase() === " AND ") { + return { + left: expression.slice(0, index).trim(), + right: expression.slice(index + 5).trim(), + }; + } + } + return null; +} + export function combineWhereInputs(manualWhereInput?: string, structuredWhereInput?: string): string | undefined { const manual = normalizeWhereInput(manualWhereInput); const structured = normalizeWhereInput(structuredWhereInput); diff --git a/packages/app-tests/dataGridColumnFilter.test.ts b/packages/app-tests/dataGridColumnFilter.test.ts index b705bae51f..428946cef5 100644 --- a/packages/app-tests/dataGridColumnFilter.test.ts +++ b/packages/app-tests/dataGridColumnFilter.test.ts @@ -1,6 +1,16 @@ import { strict as assert } from "node:assert"; import { test } from "vitest"; -import { appendColumnValueFilterCondition, buildColumnValueFilterCondition, buildColumnValuesFilterCondition, filterModeHasCompleteValue, filterModeIsSupportedForDatabase, parseFilterValue, parseFilterValues } from "../../apps/desktop/src/lib/dataGrid/dataGridColumnFilter.ts"; +import { + appendColumnValueFilterCondition, + buildColumnValueFilterCondition, + buildColumnValuesFilterCondition, + filterModeHasCompleteValue, + filterModeIsSupportedForDatabase, + parseFilterValue, + parseFilterValues, + removeColumnValueFilterCondition, + replaceColumnValueFilterCondition, +} from "../../apps/desktop/src/lib/dataGrid/dataGridColumnFilter.ts"; import { buildDataGridContextFilterCondition } from "../../apps/desktop/src/lib/dataGrid/dataGridSql.ts"; let lastContextFilterOptions: Record | undefined; @@ -60,6 +70,23 @@ test("quotes text server-side column filters and appends them to existing WHERE assert.equal(appendColumnValueFilterCondition("deleted_at IS NULL", condition), `(deleted_at IS NULL) AND ("status" = 'active')`); }); +test("replaces a repeated database value filter without stacking incompatible conditions", () => { + const firstStatus = `"status" = 'active'`; + const secondStatus = `"status" IN ('pending', 'disabled')`; + const initial = appendColumnValueFilterCondition("deleted_at IS NULL", firstStatus); + + assert.equal(replaceColumnValueFilterCondition(initial, firstStatus, secondStatus), `(deleted_at IS NULL) AND ("status" IN ('pending', 'disabled'))`); +}); + +test("removes nested database value filters while preserving unrelated predicates", () => { + const status = `"status" = 'active'`; + const tenant = `"tenant_id" = 42`; + const whereInput = appendColumnValueFilterCondition(appendColumnValueFilterCondition("deleted_at IS NULL", status), tenant); + + assert.equal(removeColumnValueFilterCondition(whereInput, status), `(deleted_at IS NULL) AND ("tenant_id" = 42)`); + assert.equal(removeColumnValueFilterCondition(`score BETWEEN 10 AND 20`, status), `score BETWEEN 10 AND 20`); +}); + test("builds IS NULL for typed NULL filters", async () => { installFilterFetchMock(); const condition = await buildColumnValueFilterCondition({