From e38e6b468f5b34585fc8663b890459a398559196 Mon Sep 17 00:00:00 2001
From: t8y2 <1156263951@qq.com>
Date: Mon, 18 May 2026 16:00:06 +0800
Subject: [PATCH] feat(grid): filter typed column values
---
apps/desktop/src/components/grid/DataGrid.vue | 61 ++++++++++++++++++-
apps/desktop/src/i18n/locales/en.ts | 1 +
apps/desktop/src/i18n/locales/es.ts | 1 +
apps/desktop/src/i18n/locales/zh-CN.ts | 1 +
apps/desktop/src/lib/dataGridColumnFilter.ts | 61 +++++++++++++++++++
.../app-tests/dataGridColumnFilter.test.ts | 39 ++++++++++++
6 files changed, 162 insertions(+), 2 deletions(-)
create mode 100644 apps/desktop/src/lib/dataGridColumnFilter.ts
create mode 100644 packages/app-tests/dataGridColumnFilter.test.ts
diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue
index ad07f4c32a..7c8fa30c7b 100644
--- a/apps/desktop/src/components/grid/DataGrid.vue
+++ b/apps/desktop/src/components/grid/DataGrid.vue
@@ -104,6 +104,7 @@ import {
import { isCancelSearchShortcut, isFocusSearchShortcut } from "@/lib/keyboardShortcuts";
import { dataGridHeaderContentWidth, scrollbarGutterWidth } from "@/lib/dataGridScrollGutter";
import { dataGridSaveActionMode } from "@/lib/dataGridSaveUi";
+import { appendColumnValueFilterCondition, buildColumnValueFilterCondition } from "@/lib/dataGridColumnFilter";
import {
filterColumnVisibilityOptions,
nextHiddenColumnIndexes,
@@ -411,6 +412,23 @@ const localFilterAllVisibleSelected = computed(() => {
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) {
localFilterSearch.value = "";
const allKeys = buildLocalFilterOptions(colIdx).map((option) => option.key);
@@ -594,9 +612,17 @@ function toggleAllLocalFilterOptions() {
localFilterDraft.value = { ...draft, values: next };
}
-function applyLocalFilter() {
+async function applyLocalFilter() {
const draft = localFilterDraft.value;
if (!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;
@@ -614,6 +640,26 @@ function applyLocalFilter() {
resetGridVerticalScroll();
}
+async function applyTypedLocalFilterValue() {
+ const draft = localFilterDraft.value;
+ if (!draft) return;
+ const columnName = props.result.columns[draft.columnIndex];
+ if (!columnName) return;
+ const condition = buildColumnValueFilterCondition({
+ databaseType: props.databaseType,
+ columnName,
+ columnInfo: props.tableMeta?.columns.find((column) => column.name === columnName),
+ rawValue: localFilterTypedValue.value,
+ });
+ if (!condition) return;
+ const next = { ...localColumnFilters.value };
+ delete next[draft.columnIndex];
+ localColumnFilters.value = next;
+ whereFilterInput.value = appendColumnValueFilterCondition(whereFilterInput.value, condition);
+ closeLocalFilter();
+ await applyWhereFilter();
+}
+
function clearLocalFilter(colIdx?: number) {
if (colIdx === undefined) {
localColumnFilters.value = {};
@@ -3243,8 +3289,19 @@ defineExpose({
})
}}
+
{{ t("grid.noSearchResults") }}
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts
index f524218c2f..282b126536 100644
--- a/apps/desktop/src/i18n/locales/en.ts
+++ b/apps/desktop/src/i18n/locales/en.ts
@@ -289,6 +289,7 @@ export default {
saveFormatter: "Save",
clearFormatter: "Clear",
searchValues: "Search values...",
+ filterTypedValue: "Use '{value}'",
value: "Value",
count: "Count",
applyFilter: "Apply Filter",
diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts
index cba5edb55c..1b7d2e35b2 100644
--- a/apps/desktop/src/i18n/locales/es.ts
+++ b/apps/desktop/src/i18n/locales/es.ts
@@ -265,6 +265,7 @@ export default {
localFilter: "Filtro local de columna",
localFilterFor: "Filtro local para '{column}'",
searchValues: "Buscar valores...",
+ filterTypedValue: "Usar '{value}'",
value: "Valor",
count: "Conteo",
applyFilter: "Aplicar filtro",
diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts
index 299059844a..e113ce2e99 100644
--- a/apps/desktop/src/i18n/locales/zh-CN.ts
+++ b/apps/desktop/src/i18n/locales/zh-CN.ts
@@ -285,6 +285,7 @@ export default {
saveFormatter: "保存",
clearFormatter: "清除",
searchValues: "搜索值...",
+ filterTypedValue: "使用“{value}”筛选",
value: "值",
count: "数量",
applyFilter: "应用筛选",
diff --git a/apps/desktop/src/lib/dataGridColumnFilter.ts b/apps/desktop/src/lib/dataGridColumnFilter.ts
new file mode 100644
index 0000000000..dac9fe8cca
--- /dev/null
+++ b/apps/desktop/src/lib/dataGridColumnFilter.ts
@@ -0,0 +1,61 @@
+import type { ColumnInfo, DatabaseType } from "@/types/database";
+import { formatGridSqlLiteral } from "@/lib/dataGridSql";
+import { quoteTableIdentifier, normalizeWhereInput } from "@/lib/tableSelectSql";
+
+export function buildColumnValueFilterCondition(options: {
+ databaseType?: DatabaseType;
+ columnName: string;
+ columnInfo?: Pick;
+ rawValue: string;
+}): string | undefined {
+ const text = options.rawValue.trim();
+ if (!text) return undefined;
+
+ const column = columnFilterRef(options.databaseType, options.columnName);
+ if (/^null$/i.test(text)) return `${column} IS NULL`;
+ return `${column} = ${formatGridSqlLiteral(parseTypedFilterValue(text, options.columnInfo), options.databaseType)}`;
+}
+
+export function appendColumnValueFilterCondition(
+ whereInput: string | undefined,
+ condition: string | undefined,
+): string {
+ if (!condition) return normalizeWhereInput(whereInput);
+ const existing = normalizeWhereInput(whereInput);
+ return existing ? `(${existing}) AND (${condition})` : condition;
+}
+
+function columnFilterRef(databaseType: DatabaseType | undefined, columnName: string): string {
+ const quoted = quoteTableIdentifier(databaseType, columnName);
+ return databaseType === "neo4j" ? `n.${quoted}` : quoted;
+}
+
+function parseTypedFilterValue(text: string, columnInfo: Pick | undefined) {
+ const unquoted = unwrapMatchingQuotes(text);
+ const dataType = columnInfo?.data_type.toLowerCase() ?? "";
+ if (isBooleanType(dataType) && /^(true|false)$/i.test(unquoted)) return /^true$/i.test(unquoted);
+ if ((isNumericType(dataType) || !dataType) && isNumericLiteral(unquoted)) return Number(unquoted);
+ return unquoted;
+}
+
+function unwrapMatchingQuotes(text: string): string {
+ if (text.length < 2) return text;
+ const first = text[0];
+ const last = text[text.length - 1];
+ if ((first === "'" && last === "'") || (first === '"' && last === '"')) return text.slice(1, -1);
+ return text;
+}
+
+function isNumericType(dataType: string): boolean {
+ return /\b(int|integer|bigint|smallint|tinyint|mediumint|serial|number|numeric|decimal|float|double|real|money)\b/i.test(
+ dataType,
+ );
+}
+
+function isBooleanType(dataType: string): boolean {
+ return /\b(bool|boolean|bit)\b/i.test(dataType);
+}
+
+function isNumericLiteral(text: string): boolean {
+ return /^[+-]?(?:\d+|\d*\.\d+)(?:e[+-]?\d+)?$/i.test(text) && Number.isFinite(Number(text));
+}
diff --git a/packages/app-tests/dataGridColumnFilter.test.ts b/packages/app-tests/dataGridColumnFilter.test.ts
new file mode 100644
index 0000000000..2c2bb8d2c5
--- /dev/null
+++ b/packages/app-tests/dataGridColumnFilter.test.ts
@@ -0,0 +1,39 @@
+import { strict as assert } from "node:assert";
+import test from "node:test";
+import {
+ appendColumnValueFilterCondition,
+ buildColumnValueFilterCondition,
+} from "../../apps/desktop/src/lib/dataGridColumnFilter.ts";
+
+test("builds a numeric server-side column filter from typed text", () => {
+ const condition = buildColumnValueFilterCondition({
+ databaseType: "mysql",
+ columnName: "id",
+ columnInfo: { name: "id", data_type: "int", is_nullable: false, is_primary_key: true },
+ rawValue: "49436",
+ });
+
+ assert.equal(condition, "`id` = 49436");
+});
+
+test("quotes text server-side column filters and appends them to existing WHERE input", () => {
+ const condition = buildColumnValueFilterCondition({
+ databaseType: "postgres",
+ columnName: "status",
+ columnInfo: { name: "status", data_type: "varchar", is_nullable: true, is_primary_key: false },
+ rawValue: "active",
+ });
+
+ assert.equal(condition, `"status" = 'active'`);
+ assert.equal(appendColumnValueFilterCondition("deleted_at IS NULL", condition), `(deleted_at IS NULL) AND ("status" = 'active')`);
+});
+
+test("builds IS NULL for typed NULL filters", () => {
+ const condition = buildColumnValueFilterCondition({
+ databaseType: "sqlserver",
+ columnName: "archived_at",
+ rawValue: "NULL",
+ });
+
+ assert.equal(condition, "[archived_at] IS NULL");
+});