fix(grid): replace repeated database value filters
This commit is contained in:
parent
b5c6e7c0c1
commit
62bf456261
|
|
@ -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<number, CachedServerColumnFilter>;
|
||||
};
|
||||
const structuredFilterStateCache = new Map<string, StructuredFilterCacheState>();
|
||||
</script>
|
||||
|
|
@ -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<LocalFilterOption[]>([]);
|
||||
const serverFilterLimited = ref(false);
|
||||
const serverFilterValueByKey = ref<Map<string, CellValue>>(new Map());
|
||||
const serverColumnFilters = ref<Record<number, CachedServerColumnFilter>>({});
|
||||
let serverFilterRequestId = 0;
|
||||
let serverFilterSearchTimer: ReturnType<typeof window.setTimeout> | 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<ContextMenuItem[]>(() => {
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<slot name="search-bar" :local-filter-count="localFilterCount" :has-local-column-filters="hasLocalColumnFilters" :local-filter-summaries="localFilterSummaries" :clear-local-filter="clearLocalFilter" />
|
||||
<slot name="search-bar" :local-filter-count="localFilterCount + serverColumnFilterCount" :has-local-column-filters="hasLocalColumnFilters || hasServerColumnFilters" :local-filter-summaries="localFilterSummaries" :clear-local-filter="clearLocalFilter" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> | 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({
|
||||
|
|
|
|||
Loading…
Reference in New Issue