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"); +});