From 69b43cf4017a197a3c5fb632ed8cfbb7b7d99057 Mon Sep 17 00:00:00 2001 From: dienaso <89768641@qq.com> Date: Tue, 22 Sep 2026 16:22:07 +0800 Subject: [PATCH] fix(grid): treat bulk-edit NULL keyword as SQL NULL --- apps/desktop/src/components/grid/DataGrid.vue | 10 ++-- .../__tests__/DataGridInlineBulkEdit.spec.ts | 18 ++++++- .../lib/dataGrid/dataGridInlineBulkEdit.ts | 12 +++++ crates/dbx-sql/src/data_grid_sql.rs | 49 +++++++++++++++++++ 4 files changed, 84 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 12410de5d6..f5cd8e7b6f 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -308,7 +308,7 @@ import { uiTuning } from "@/lib/app/uiTuning"; import { useDataGridScrollbars, type DataGridScrollbarsRuntime } from "@/composables/useDataGridScrollbars"; import { useDataGridSelection } from "@/composables/useDataGridSelection"; import { dataGridNavigationOrigin, dataGridPageScrollTop, dataGridRowScrollTop, moveDataGridCell, navigateDataGridCell, type DataGridNavigationDirection, type DataGridScrollAlignment } from "@/lib/dataGrid/dataGridNavigation"; -import { dataGridInlineBulkEditValue } from "@/lib/dataGrid/dataGridInlineBulkEdit"; +import { bulkEditInputToSqlValue, dataGridInlineBulkEditValue } from "@/lib/dataGrid/dataGridInlineBulkEdit"; import type { CellPosition } from "@/lib/dataGrid/gridSelection"; import type { GridSnapshotSource } from "@/lib/gridSnapshot/gridSnapshot"; import { createDataGridRuntimeScope } from "@/lib/dataGrid/dataGridRuntime"; @@ -8442,7 +8442,8 @@ async function applyBulkEditValue() { toast(t("grid.conditionalBulkEditConditionRequired"), 5000); return; } - const value = bulkEditValue.value === "" ? null : coerceCellValue(bulkEditValue.value, undefined, target.columnIndex); + const rawValue = bulkEditInputToSqlValue(bulkEditValue.value); + const value = rawValue === null ? null : coerceCellValue(rawValue, undefined, target.columnIndex); const statement = await buildDataGridConditionalUpdateSql({ databaseType: resolvedDatabaseType.value, identifierQuote: connectionStore.connectionIdentifierQuote?.(props.connectionId), @@ -8464,8 +8465,9 @@ async function applyBulkEditValue() { return; } - // Empty input sets the selected cells to SQL NULL (the placeholder hints "Value, or NULL"). - const value = bulkEditValue.value === "" ? null : bulkEditValue.value; + // Empty input or the bare keyword NULL sets the selected cells to SQL NULL + // (the placeholder hints "Value, or NULL"). + const value = bulkEditInputToSqlValue(bulkEditValue.value); if (!fillSelectionWithValue(value)) return; bulkEditDialogOpen.value = false; } diff --git a/apps/desktop/src/components/grid/__tests__/DataGridInlineBulkEdit.spec.ts b/apps/desktop/src/components/grid/__tests__/DataGridInlineBulkEdit.spec.ts index 0d89c73cc5..19f53a388c 100644 --- a/apps/desktop/src/components/grid/__tests__/DataGridInlineBulkEdit.spec.ts +++ b/apps/desktop/src/components/grid/__tests__/DataGridInlineBulkEdit.spec.ts @@ -1,6 +1,6 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; -import { dataGridInlineBulkEditValue } from "@/lib/dataGrid/dataGridInlineBulkEdit"; +import { bulkEditInputToSqlValue, dataGridInlineBulkEditValue } from "@/lib/dataGrid/dataGridInlineBulkEdit"; const dataGridSource = readFileSync(new URL("../DataGrid.vue", import.meta.url), "utf8"); @@ -40,3 +40,19 @@ describe("DataGrid inline bulk editing", () => { expect(commit).toContain("cancelEdit()"); }); }); + +describe("bulkEditInputToSqlValue", () => { + it.each(["", " ", "NULL", "null", "Null", "nUlL", " NULL "])("maps the NULL sentinel %j to SQL NULL", (input) => { + expect(bulkEditInputToSqlValue(input)).toBeNull(); + }); + + it.each(["0", "false", "NULLL", "NULLX", "'NULL'", "nulls", "NUL", " 1 "])("keeps non-sentinel input %j as typed", (input) => { + expect(bulkEditInputToSqlValue(input)).toBe(input); + }); + + it("routes applyBulkEditValue through bulkEditInputToSqlValue for both scopes", () => { + const applyBulkEdit = functionBody("applyBulkEditValue", "confirmConditionalBulkEdit"); + expect(applyBulkEdit).toContain("bulkEditInputToSqlValue(bulkEditValue.value)"); + expect(applyBulkEdit).not.toContain('bulkEditValue.value === "" ? null'); + }); +}); diff --git a/apps/desktop/src/lib/dataGrid/dataGridInlineBulkEdit.ts b/apps/desktop/src/lib/dataGrid/dataGridInlineBulkEdit.ts index e1a968fdf4..642ad65298 100644 --- a/apps/desktop/src/lib/dataGrid/dataGridInlineBulkEdit.ts +++ b/apps/desktop/src/lib/dataGrid/dataGridInlineBulkEdit.ts @@ -12,3 +12,15 @@ export function dataGridInlineBulkEditValue(event: DataGridInlineBulkEditKeyEven if (event.key === "Enter") return ""; return event.key !== "Process" && Array.from(event.key).length === 1 ? event.key : undefined; } + +/** + * Bulk-edit dialog contract (placeholder "Value, or NULL"): empty input and the + * bare keyword `NULL` stage SQL NULL. Every other input is kept as typed so a + * text column can still receive the literal word via quoted/user-specific input + * outside this dialog path. + */ +export function bulkEditInputToSqlValue(input: string): string | null { + const trimmed = input.trim(); + if (trimmed === "" || trimmed.toUpperCase() === "NULL") return null; + return input; +} diff --git a/crates/dbx-sql/src/data_grid_sql.rs b/crates/dbx-sql/src/data_grid_sql.rs index 3401fcedcf..7c7bf7cf2b 100644 --- a/crates/dbx-sql/src/data_grid_sql.rs +++ b/crates/dbx-sql/src/data_grid_sql.rs @@ -8413,6 +8413,55 @@ mod tests { ); } + #[test] + fn saves_json_null_bigint_cell_as_unquoted_sql_null() { + // Regression for #9970: bulk-editing a bigint column to NULL must emit + // `= NULL`, never `= 'NULL'` / `= ''` (ERROR 1366 on MySQL/OceanBase). + let result = prepare_data_grid_save(DataGridSaveStatementOptions { + database_type: Some(DatabaseType::Mysql), + identifier_quote: None, + table_meta: DataGridTableMeta { + catalog: None, + database: None, + schema: None, + table_name: "orders".to_string(), + primary_keys: vec!["id".to_string()], + columns: Some(vec![column("id", "bigint", false, None), column("detail_id", "bigint", true, None)]), + }, + columns: vec!["id".to_string(), "detail_id".to_string()], + source_columns: None, + rows: vec![vec![json!(7), json!(42)]], + dirty_rows: vec![(0, vec![(1, Value::Null)])], + deleted_rows: vec![], + new_rows: vec![], + include_database_name: false, + }); + + assert_eq!(result.validation_error, None); + assert_eq!(result.statements, vec!["UPDATE `orders` SET `detail_id` = NULL WHERE `id` = 7;"]); + } + + #[test] + fn mysql_conditional_update_null_bigint_is_unquoted_sql_null() { + let statement = build_data_grid_conditional_update_sql(DataGridConditionalUpdateSqlOptions { + database_type: Some(DatabaseType::Mysql), + identifier_quote: None, + table_meta: DataGridTableMeta { + catalog: None, + database: None, + schema: None, + table_name: "orders".to_string(), + primary_keys: vec!["id".to_string()], + columns: Some(vec![column("id", "bigint", false, None), column("detail_id", "bigint", true, None)]), + }, + column_name: "detail_id".to_string(), + value: Value::Null, + where_input: "tenant_id = 1".to_string(), + }); + + assert_eq!(statement, Some("UPDATE `orders` SET `detail_id` = NULL WHERE (tenant_id = 1);".to_string())); + } + #[test] fn keeps_empty_nullable_mysql_text_cell_as_empty_string() { let result = prepare_data_grid_save(DataGridSaveStatementOptions {