From e2fa810f2e3349fcb1348c37866fb041dc412739 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Mon, 24 Aug 2026 22:33:21 +0000 Subject: [PATCH] feat(grid): distinguish blank and null filters Closes #5006 --- apps/desktop/src/components/grid/DataGrid.vue | 4 +- apps/desktop/src/i18n/locales/en.ts | 2 + apps/desktop/src/i18n/locales/es.ts | 2 + apps/desktop/src/i18n/locales/it.ts | 2 + apps/desktop/src/i18n/locales/ja.ts | 2 + apps/desktop/src/i18n/locales/ko.ts | 2 + apps/desktop/src/i18n/locales/pt-BR.ts | 2 + apps/desktop/src/i18n/locales/zh-CN.ts | 6 +- apps/desktop/src/i18n/locales/zh-TW.ts | 6 +- .../dataGrid/columnFormatter.spec.ts | 13 +++ ...taGridColumnFilter.victoriametrics.spec.ts | 2 +- .../src/lib/dataGrid/columnFormatter.ts | 4 +- .../src/lib/dataGrid/dataGridColumnFilter.ts | 2 +- apps/desktop/src/lib/dataGrid/dataGridSql.ts | 2 +- crates/dbx-core/src/data_grid_sql.rs | 83 +++++++++++++++++++ .../app-tests/dataGridColumnFilter.test.ts | 22 +++++ 16 files changed, 146 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index af8fbc20ed..dfb82fae09 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -1036,6 +1036,8 @@ const allFilterModeOptions: Array<{ value: FilterMode; labelKey: string }> = [ { value: "not-between", labelKey: "grid.filterBuilderNotBetween" }, { value: "is-null", labelKey: "grid.filterBuilderIsNull" }, { value: "is-not-null", labelKey: "grid.filterBuilderIsNotNull" }, + { value: "is-blank", labelKey: "grid.filterBuilderIsBlank" }, + { value: "is-not-blank", labelKey: "grid.filterBuilderIsNotBlank" }, ]; const filterModeOptions = computed(() => allFilterModeOptions.filter((option) => filterModeIsSupportedForDatabase(option.value, resolvedDatabaseType.value))); const filterBuilderColumnOptions = computed(() => filterBuilderColumns.value.map((column) => column.name)); @@ -6372,7 +6374,7 @@ async function contextFilterCondition(target: ContextFilterTarget, mode: FilterM const { columnName, sourceResult, sourceIndex, sourceValue, requiresHydration } = target; // CustomContextMenu closes before invoking its action. Keep the target // stable across hydration after the close lifecycle clears contextCell. - if (mode !== "is-null" && mode !== "is-not-null") { + if (filterModeNeedsValue(mode)) { if (!(await hydrateLargeValueCell(target.rowId, target.col))) return null; } if (props.result !== sourceResult) return null; diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 3f57366bf4..06222f4776 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -1711,6 +1711,8 @@ export default { filterBuilderNotBetween: "Outside range", filterBuilderIsNull: "Is NULL", filterBuilderIsNotNull: "Is not NULL", + filterBuilderIsBlank: "Is blank (NULL or empty)", + filterBuilderIsNotBlank: "Is not blank (not NULL and not empty)", columnActions: "Column actions", localFilter: "Local value filter", localFilterFor: "Local Values For '{column}'", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index d400f3d24d..880cda0fbf 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -1594,6 +1594,8 @@ export default withEnglishFallback({ filterBuilderNotBetween: "Fuera de rango", filterBuilderIsNull: "Es NULL", filterBuilderIsNotNull: "No es NULL", + filterBuilderIsBlank: "Está vacío (NULL o cadena vacía)", + filterBuilderIsNotBlank: "No está vacío (ni NULL ni cadena vacía)", columnActions: "Acciones de columna", localFilter: "Filtro local de valores", localFilterFor: "Valores locales para '{column}'", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index c0d6e16859..9f988a3735 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -1592,6 +1592,8 @@ export default withEnglishFallback({ filterBuilderNotBetween: "Fuori intervallo", filterBuilderIsNull: "È NULL", filterBuilderIsNotNull: "Non è NULL", + filterBuilderIsBlank: "È vuoto (NULL o stringa vuota)", + filterBuilderIsNotBlank: "Non è vuoto (né NULL né stringa vuota)", columnActions: "Azioni colonna", localFilter: "Filtro valori locale", localFilterFor: "Valori locali per '{column}'", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 70b6769f13..0b2d95dcab 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -1610,6 +1610,8 @@ export default withEnglishFallback({ filterBuilderNotBetween: "範囲外", filterBuilderIsNull: "NULLである", filterBuilderIsNotNull: "NULLでない", + filterBuilderIsBlank: "空(NULLまたは空文字)", + filterBuilderIsNotBlank: "空でない(NULLでも空文字でもない)", columnActions: "列の操作", localFilter: "ローカル値フィルター", localFilterFor: "'{column}'のローカル値", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 5e9af8f985..e230a5a0fe 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -1587,6 +1587,8 @@ export default withEnglishFallback({ filterBuilderNotBetween: "범위 외", filterBuilderIsNull: "NULL임", filterBuilderIsNotNull: "NULL이 아님", + filterBuilderIsBlank: "비어 있음(NULL 또는 빈 문자열)", + filterBuilderIsNotBlank: "비어 있지 않음(NULL도 빈 문자열도 아님)", columnActions: "컬럼 작업", localFilter: "로컬 값 필터", localFilterFor: "'{column}' 로컬 값", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 28a21abccd..a6321b9e21 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -1594,6 +1594,8 @@ export default withEnglishFallback({ filterBuilderNotBetween: "Fora do intervalo", filterBuilderIsNull: "É NULL", filterBuilderIsNotNull: "Não é NULL", + filterBuilderIsBlank: "Está em branco (NULL ou string vazia)", + filterBuilderIsNotBlank: "Não está em branco (nem NULL nem string vazia)", columnActions: "Ações da coluna", localFilter: "Filtro local de valores", localFilterFor: "Valores locais para '{column}'", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index d8a1056012..edee0ecc49 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -1634,8 +1634,10 @@ export default withEnglishFallback({ filterBuilderNotIn: "列表外", filterBuilderBetween: "介于", filterBuilderNotBetween: "不介于", - filterBuilderIsNull: "为空", - filterBuilderIsNotNull: "不为空", + filterBuilderIsNull: "为 NULL", + filterBuilderIsNotNull: "不为 NULL", + filterBuilderIsBlank: "为空(NULL 或空字符串)", + filterBuilderIsNotBlank: "不为空(非 NULL 且非空字符串)", columnActions: "字段操作", localFilter: "本地值筛选", localFilterFor: "本地值筛选“{column}”", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 6a27a45d3d..f1db12f980 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -1591,8 +1591,10 @@ export default withEnglishFallback({ filterBuilderNotIn: "清單外", filterBuilderBetween: "介於", filterBuilderNotBetween: "不介於", - filterBuilderIsNull: "為空", - filterBuilderIsNotNull: "不為空", + filterBuilderIsNull: "為 NULL", + filterBuilderIsNotNull: "不為 NULL", + filterBuilderIsBlank: "為空(NULL 或空字串)", + filterBuilderIsNotBlank: "不為空(非 NULL 且非空字串)", columnActions: "欄位操作", localFilter: "本機值篩選", localFilterFor: "本機值篩選「{column}」", diff --git a/apps/desktop/src/lib/__tests__/dataGrid/columnFormatter.spec.ts b/apps/desktop/src/lib/__tests__/dataGrid/columnFormatter.spec.ts index 1e9225bfac..b1c39bf7b5 100644 --- a/apps/desktop/src/lib/__tests__/dataGrid/columnFormatter.spec.ts +++ b/apps/desktop/src/lib/__tests__/dataGrid/columnFormatter.spec.ts @@ -36,6 +36,19 @@ describe("normalizeColumnFormatter", () => { expect(normalizeColumnFormatter({ ...base, filter: { column: "dict_type", mode: "between", value: "A" } })).toBeUndefined(); expect(normalizeColumnFormatter({ ...base, filter: { column: "dict_type", mode: "raw-sql", value: "1=1" } })).toBeUndefined(); }); + + it.each(["is-blank", "is-not-blank"] as const)("preserves the value-less %s manual reference filter", (mode) => { + expect( + normalizeColumnFormatter({ + kind: "foreign-key-display", + referenceMode: "manual", + refTable: "dictionary", + refColumn: "dict_key", + displayColumn: "dict_value", + filter: { column: "dict_type", mode }, + }), + ).toMatchObject({ filter: { column: "dict_type", mode, value: undefined, endValue: undefined } }); + }); }); describe("normalizeSupportedDateTimePattern", () => { diff --git a/apps/desktop/src/lib/__tests__/dataGrid/dataGridColumnFilter.victoriametrics.spec.ts b/apps/desktop/src/lib/__tests__/dataGrid/dataGridColumnFilter.victoriametrics.spec.ts index 41b4342b48..66bf4d38f3 100644 --- a/apps/desktop/src/lib/__tests__/dataGrid/dataGridColumnFilter.victoriametrics.spec.ts +++ b/apps/desktop/src/lib/__tests__/dataGrid/dataGridColumnFilter.victoriametrics.spec.ts @@ -4,7 +4,7 @@ import type { DataGridContextFilterMode } from "@/lib/dataGrid/dataGridSql"; describe("VictoriaMetrics data grid filters", () => { it("does not expose SQL filter modes for MetricsQL results", () => { - const modes: DataGridContextFilterMode[] = ["equals", "not-equals", "is-null", "is-not-null", "like", "not-like", "less-than", "greater-than", "in", "not-in", "between", "not-between"]; + const modes: DataGridContextFilterMode[] = ["equals", "not-equals", "is-null", "is-not-null", "is-blank", "is-not-blank", "like", "not-like", "less-than", "greater-than", "in", "not-in", "between", "not-between"]; expect(modes.every((mode) => !filterModeIsSupportedForDatabase(mode, "victoriametrics"))).toBe(true); expect(filterModeIsSupportedForDatabase("equals", "postgres")).toBe(true); diff --git a/apps/desktop/src/lib/dataGrid/columnFormatter.ts b/apps/desktop/src/lib/dataGrid/columnFormatter.ts index b777338a14..b30098e7c9 100644 --- a/apps/desktop/src/lib/dataGrid/columnFormatter.ts +++ b/apps/desktop/src/lib/dataGrid/columnFormatter.ts @@ -190,7 +190,7 @@ export function normalizeColumnFormatter(value: unknown): ColumnFormatterConfig return undefined; } -const FOREIGN_KEY_DISPLAY_FILTER_MODES = new Set(["equals", "not-equals", "is-null", "is-not-null", "like", "not-like", "less-than", "less-than-or-equal", "greater-than", "greater-than-or-equal", "in", "not-in", "between", "not-between"]); +const FOREIGN_KEY_DISPLAY_FILTER_MODES = new Set(["equals", "not-equals", "is-null", "is-not-null", "is-blank", "is-not-blank", "like", "not-like", "less-than", "less-than-or-equal", "greater-than", "greater-than-or-equal", "in", "not-in", "between", "not-between"]); function normalizeForeignKeyDisplayFilter(value: unknown): ForeignKeyDisplayFilterConfig | undefined { if (!value || typeof value !== "object") return undefined; @@ -200,7 +200,7 @@ function normalizeForeignKeyDisplayFilter(value: unknown): ForeignKeyDisplayFilt const mode = filter.mode as DataGridContextFilterMode; const rawValue = typeof filter.value === "string" ? filter.value.trim() : ""; const rawEndValue = typeof filter.endValue === "string" ? filter.endValue.trim() : ""; - if (mode !== "is-null" && mode !== "is-not-null" && !rawValue) return undefined; + if (mode !== "is-null" && mode !== "is-not-null" && mode !== "is-blank" && mode !== "is-not-blank" && !rawValue) return undefined; if ((mode === "between" || mode === "not-between") && !rawEndValue) return undefined; return { column: filter.column.trim(), diff --git a/apps/desktop/src/lib/dataGrid/dataGridColumnFilter.ts b/apps/desktop/src/lib/dataGrid/dataGridColumnFilter.ts index 73d6d5e171..ffc41a9cb8 100644 --- a/apps/desktop/src/lib/dataGrid/dataGridColumnFilter.ts +++ b/apps/desktop/src/lib/dataGrid/dataGridColumnFilter.ts @@ -172,7 +172,7 @@ export function combineWhereInputs(manualWhereInput?: string, structuredWhereInp } export function filterModeNeedsValue(mode: DataGridContextFilterMode): boolean { - return mode !== "is-null" && mode !== "is-not-null"; + return mode !== "is-null" && mode !== "is-not-null" && mode !== "is-blank" && mode !== "is-not-blank"; } export function filterModeUsesList(mode: DataGridContextFilterMode): boolean { diff --git a/apps/desktop/src/lib/dataGrid/dataGridSql.ts b/apps/desktop/src/lib/dataGrid/dataGridSql.ts index 64fdb84e45..198dabbae3 100644 --- a/apps/desktop/src/lib/dataGrid/dataGridSql.ts +++ b/apps/desktop/src/lib/dataGrid/dataGridSql.ts @@ -58,7 +58,7 @@ export interface DataGridCopyInsertStatementOptions { insertMode?: DataGridCopyInsertMode; } -export type DataGridContextFilterMode = "equals" | "not-equals" | "is-null" | "is-not-null" | "like" | "not-like" | "less-than" | "less-than-or-equal" | "greater-than" | "greater-than-or-equal" | "in" | "not-in" | "between" | "not-between"; +export type DataGridContextFilterMode = "equals" | "not-equals" | "is-null" | "is-not-null" | "is-blank" | "is-not-blank" | "like" | "not-like" | "less-than" | "less-than-or-equal" | "greater-than" | "greater-than-or-equal" | "in" | "not-in" | "between" | "not-between"; export interface DataGridContextFilterConditionOptions { databaseType?: DatabaseType; diff --git a/crates/dbx-core/src/data_grid_sql.rs b/crates/dbx-core/src/data_grid_sql.rs index b00e5198d6..c3895e9c47 100644 --- a/crates/dbx-core/src/data_grid_sql.rs +++ b/crates/dbx-core/src/data_grid_sql.rs @@ -149,6 +149,8 @@ pub enum DataGridContextFilterMode { NotEquals, IsNull, IsNotNull, + IsBlank, + IsNotBlank, Like, NotLike, LessThan, @@ -546,6 +548,18 @@ pub fn build_data_grid_context_filter_condition(options: DataGridContextFilterCo match options.mode { DataGridContextFilterMode::IsNull => Some(format!("{column} IS NULL")), DataGridContextFilterMode::IsNotNull => Some(format!("{column} IS NOT NULL")), + DataGridContextFilterMode::IsBlank + if matches!(options.database_type, Some(DatabaseType::Oracle | DatabaseType::OceanbaseOracle)) => + { + Some(format!("{column} IS NULL")) + } + DataGridContextFilterMode::IsNotBlank + if matches!(options.database_type, Some(DatabaseType::Oracle | DatabaseType::OceanbaseOracle)) => + { + Some(format!("{column} IS NOT NULL")) + } + DataGridContextFilterMode::IsBlank => Some(format!("({column} IS NULL OR {column} = '')")), + DataGridContextFilterMode::IsNotBlank => Some(format!("({column} IS NOT NULL AND {column} <> '')")), DataGridContextFilterMode::Equals if value.is_null() => Some(format!("{column} IS NULL")), DataGridContextFilterMode::NotEquals if value.is_null() => Some(format!("{column} IS NOT NULL")), DataGridContextFilterMode::Like => Some(format!( @@ -4152,6 +4166,73 @@ mod tests { ); } + #[test] + fn builds_blank_and_nonblank_context_filter_conditions() { + let build = |database_type: DatabaseType, + mode: DataGridContextFilterMode, + identifier_quote: Option<&str>, + column_name: &str| { + build_data_grid_context_filter_condition(DataGridContextFilterConditionOptions { + database_type: Some(database_type), + identifier_quote: identifier_quote.map(str::to_string), + column_name: column_name.to_string(), + mode, + value: Value::Null, + values: Vec::new(), + end_value: None, + column_info: Some(column(column_name, "varchar", true, None)), + }) + }; + + assert_eq!( + build(DatabaseType::Mysql, DataGridContextFilterMode::IsBlank, None, "status"), + Some("(`status` IS NULL OR `status` = '')".to_string()) + ); + assert_eq!( + build(DatabaseType::Mysql, DataGridContextFilterMode::IsNotBlank, None, "status"), + Some("(`status` IS NOT NULL AND `status` <> '')".to_string()) + ); + assert_eq!( + build(DatabaseType::Mysql, DataGridContextFilterMode::IsNull, None, "status"), + Some("`status` IS NULL".to_string()) + ); + assert_eq!( + build(DatabaseType::Mysql, DataGridContextFilterMode::IsNotNull, None, "status"), + Some("`status` IS NOT NULL".to_string()) + ); + assert_eq!( + build(DatabaseType::Kingbase, DataGridContextFilterMode::IsBlank, Some("`"), "order detail"), + Some("(`order detail` IS NULL OR `order detail` = '')".to_string()) + ); + + for database_type in [DatabaseType::Oracle, DatabaseType::OceanbaseOracle] { + assert_eq!( + build(database_type, DataGridContextFilterMode::IsBlank, None, "STATUS"), + Some("\"STATUS\" IS NULL".to_string()) + ); + assert_eq!( + build(database_type, DataGridContextFilterMode::IsNotBlank, None, "STATUS"), + Some("\"STATUS\" IS NOT NULL".to_string()) + ); + } + } + + #[test] + fn keeps_context_filter_mode_serialization_stable() { + assert_eq!(serde_json::to_string(&DataGridContextFilterMode::IsNull).unwrap(), "\"is-null\""); + assert_eq!(serde_json::to_string(&DataGridContextFilterMode::IsNotNull).unwrap(), "\"is-not-null\""); + assert!(matches!( + serde_json::from_str::("\"is-null\"").unwrap(), + DataGridContextFilterMode::IsNull + )); + assert!(matches!( + serde_json::from_str::("\"is-not-null\"").unwrap(), + DataGridContextFilterMode::IsNotNull + )); + assert_eq!(serde_json::to_string(&DataGridContextFilterMode::IsBlank).unwrap(), "\"is-blank\""); + assert_eq!(serde_json::to_string(&DataGridContextFilterMode::IsNotBlank).unwrap(), "\"is-not-blank\""); + } + #[test] fn builds_oracle_synthetic_rowid_context_filter_conditions() { let equals = |database_type, column_name: &str, value| { @@ -4405,6 +4486,8 @@ mod tests { DataGridContextFilterMode::Like, DataGridContextFilterMode::GreaterThan, DataGridContextFilterMode::IsNull, + DataGridContextFilterMode::IsBlank, + DataGridContextFilterMode::IsNotBlank, DataGridContextFilterMode::In, DataGridContextFilterMode::Between, ] { diff --git a/packages/app-tests/dataGridColumnFilter.test.ts b/packages/app-tests/dataGridColumnFilter.test.ts index 10694deaf8..f5af73886f 100644 --- a/packages/app-tests/dataGridColumnFilter.test.ts +++ b/packages/app-tests/dataGridColumnFilter.test.ts @@ -136,6 +136,7 @@ test("hides list and range modes for unsupported dialects", () => { } assert.equal(filterModeIsSupportedForDatabase("between", "postgres"), true); assert.equal(filterModeIsSupportedForDatabase("is-null", "influxdb"), true); + assert.equal(filterModeIsSupportedForDatabase("is-blank", "influxdb"), true); }); test("requires usable values for structured list and range filters", () => { @@ -144,6 +145,27 @@ test("requires usable values for structured list and range filters", () => { assert.equal(filterModeHasCompleteValue("between", "10", ""), false); assert.equal(filterModeHasCompleteValue("not-between", "10", "20"), true); assert.equal(filterModeHasCompleteValue("is-null", "", ""), true); + assert.equal(filterModeHasCompleteValue("is-not-null", "", ""), true); + assert.equal(filterModeHasCompleteValue("is-blank", "", ""), true); + assert.equal(filterModeHasCompleteValue("is-not-blank", "", ""), true); +}); + +test("passes value-less blank modes through the shared context filter API", async () => { + installFilterFetchMock(); + for (const mode of ["is-blank", "is-not-blank"] as const) { + await buildDataGridContextFilterCondition({ + databaseType: "mysql", + columnName: "status", + mode, + value: null, + }); + assert.deepEqual(lastContextFilterOptions, { + databaseType: "mysql", + columnName: "status", + mode, + value: null, + }); + } }); test("passes list and range values through the shared context filter API", async () => {