From b226b445d6aefb54ee4eedd703826092c7d0d510 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Sat, 6 Jun 2026 19:03:21 +0800 Subject: [PATCH] fix(export): preserve MySQL bit literals in SQL export --- apps/desktop/src/components/grid/DataGrid.vue | 18 ++++ .../src/components/objects/ObjectBrowser.vue | 20 +++- .../src/components/sidebar/TreeItem.vue | 10 +- .../src/composables/useDataGridExport.ts | 14 ++- apps/desktop/src/lib/databaseExport.ts | 2 + apps/desktop/src/lib/exportFormats.ts | 1 + apps/desktop/src/lib/tauri.ts | 1 + crates/dbx-core/src/database_export.rs | 91 ++++++++++++++++++- crates/dbx-core/src/table_export.rs | 11 ++- 9 files changed, 158 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 76be703e8..f53fec959 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -1493,6 +1493,23 @@ const visibleSourceColumns = computed(() => { if (!props.sourceColumns || props.sourceColumns.length !== props.result.columns.length) return undefined; return visibleColumnIndexes.value.map((index) => props.sourceColumns?.[index]); }); +const tableColumnTypesByName = computed(() => { + const map = new Map(); + for (const column of props.tableMeta?.columns ?? []) { + map.set(column.name.toLocaleLowerCase(), column.data_type); + } + return map; +}); +const visibleColumnTypes = computed(() => + visibleColumnIndexes.value.map((index) => { + const resultColumn = props.result.columns[index]?.toLocaleLowerCase(); + const sourceColumn = props.sourceColumns?.[index]?.toLocaleLowerCase(); + return ( + (sourceColumn ? tableColumnTypesByName.value.get(sourceColumn) : undefined) || + (resultColumn ? tableColumnTypesByName.value.get(resultColumn) : undefined) + ); + }), +); const visibleColumnCount = computed(() => visibleColumnIndexes.value.length); const displayableColumnCount = computed(() => displayableColumnIndexes.value.length); const hiddenColumnCount = computed(() => displayableColumnCount.value - visibleColumnCount.value); @@ -3736,6 +3753,7 @@ const { database: computed(() => props.database), context: computed(() => props.context), sourceColumns: visibleSourceColumns, + columnTypes: visibleColumnTypes, whereInput: computed(() => currentWhereInput()), orderBy: computed(() => currentOrderBy()), exportBatchSize: computed(() => settingsStore.editorSettings.exportBatchSize), diff --git a/apps/desktop/src/components/objects/ObjectBrowser.vue b/apps/desktop/src/components/objects/ObjectBrowser.vue index 40317da3d..1d4fc90fc 100644 --- a/apps/desktop/src/components/objects/ObjectBrowser.vue +++ b/apps/desktop/src/components/objects/ObjectBrowser.vue @@ -789,11 +789,16 @@ async function exportStructure(row: ObjectBrowserRow) { async function exportDataLegacy(row: ObjectBrowserRow, format: "json" | "sql") { try { const schema = row.schema || selectedSchema.value; + const tableColumns = + format === "sql" + ? await api.getColumns(props.connection.id, props.database, schema || props.database, row.name) + : undefined; const queryColumns = props.connection.db_type === "neo4j" - ? (await api.getColumns(props.connection.id, props.database, schema || props.database, row.name)).map( - (column) => column.name, - ) + ? ( + tableColumns ?? + (await api.getColumns(props.connection.id, props.database, schema || props.database, row.name)) + ).map((column) => column.name) : undefined; const result = await fetchTableDataForExport({ databaseType: effectiveDatabaseType.value, @@ -824,6 +829,7 @@ async function exportDataLegacy(row: ObjectBrowserRow, format: "json" | "sql") { schema, tableName: row.name, columns: result.columns, + columnTypes: tableColumns ? columnTypesForResultColumns(result.columns, tableColumns) : undefined, rows: result.rows, }); await saveFileContent(content, `${row.name}.sql`, "SQL", "sql"); @@ -833,6 +839,14 @@ async function exportDataLegacy(row: ObjectBrowserRow, format: "json" | "sql") { } } +function columnTypesForResultColumns( + columns: string[], + tableColumns: Array<{ name: string; data_type: string }>, +): Array { + const typesByName = new Map(tableColumns.map((column) => [column.name.toLocaleLowerCase(), column.data_type])); + return columns.map((column) => typesByName.get(column.toLocaleLowerCase())); +} + async function exportData(row: ObjectBrowserRow, format: "csv" | "json" | "sql") { if (format === "csv") { await exportTableData(row, "csv"); diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue index 5535533b9..76cb6db73 100644 --- a/apps/desktop/src/components/sidebar/TreeItem.vue +++ b/apps/desktop/src/components/sidebar/TreeItem.vue @@ -2102,9 +2102,11 @@ async function exportDataLegacy(format: "csv" | "json" | "sql") { try { await connectionStore.ensureConnected(connectionId); + const tableColumns = + format === "sql" ? await api.getColumns(connectionId, database, node.schema || database, node.label) : undefined; const queryColumns = config.db_type === "neo4j" - ? (await api.getColumns(connectionId, database, node.schema || database, node.label)).map( + ? (tableColumns ?? (await api.getColumns(connectionId, database, node.schema || database, node.label))).map( (column) => column.name, ) : undefined; @@ -2154,6 +2156,7 @@ async function exportDataLegacy(format: "csv" | "json" | "sql") { schema: node.schema, tableName: node.label, columns: result.columns, + columnTypes: tableColumns ? columnTypesForResultColumns(result.columns, tableColumns) : undefined, rows: result.rows, }); await saveFileContent(content, `${node.label}.sql`, "SQL", "sql"); @@ -2163,6 +2166,11 @@ async function exportDataLegacy(format: "csv" | "json" | "sql") { } } +function columnTypesForResultColumns(columns: string[], tableColumns: ColumnInfo[]): Array { + const typesByName = new Map(tableColumns.map((column) => [column.name.toLocaleLowerCase(), column.data_type])); + return columns.map((column) => typesByName.get(column.toLocaleLowerCase())); +} + async function exportData(format: "csv" | "json" | "sql") { if (format !== "csv") { await exportDataLegacy(format); diff --git a/apps/desktop/src/composables/useDataGridExport.ts b/apps/desktop/src/composables/useDataGridExport.ts index 62df03d05..152a48aa0 100644 --- a/apps/desktop/src/composables/useDataGridExport.ts +++ b/apps/desktop/src/composables/useDataGridExport.ts @@ -14,7 +14,11 @@ import { useToast } from "@/composables/useToast"; import { displayCellValue, type CellValue } from "@/lib/cellValue"; import { tryStartExclusiveActivation, type ActionActivationGuard } from "@/lib/actionActivation"; import { copyToClipboard } from "@/lib/clipboard"; -import { buildDataGridCopyInsertStatement, buildDataGridCopyUpdateStatements } from "@/lib/dataGridSql"; +import { + buildDataGridCopyInsertStatement, + buildDataGridCopyUpdateStatements, + type DataGridTableMeta, +} from "@/lib/dataGridSql"; import { formatSqlInsert } from "@/lib/exportFormats"; import { uuid } from "@/lib/utils"; import type { DatabaseType, QueryResult } from "@/types/database"; @@ -34,12 +38,13 @@ export interface UseDataGridExportOptions { columns: ComputedRef; displayItems: ComputedRef; sql: ComputedRef; - tableMeta: ComputedRef<{ schema?: string; tableName: string; primaryKeys: string[] } | undefined>; + tableMeta: ComputedRef; databaseType: ComputedRef; connectionId: ComputedRef; database: ComputedRef; context: ComputedRef<"results" | "table-data" | undefined>; sourceColumns: ComputedRef | undefined>; + columnTypes: ComputedRef | undefined>; whereInput: ComputedRef; orderBy: ComputedRef; exportBatchSize: ComputedRef; @@ -107,6 +112,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) { context, whereInput, orderBy, + columnTypes, exportBatchSize, hasCellSelection, selectedCells, @@ -692,6 +698,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) { filePath: outputPath, format, columns: columns.value, + columnTypes: columnTypes.value, primaryKeys: meta.primaryKeys, whereInput: whereInput.value, orderBy: orderBy.value, @@ -727,6 +734,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) { schema: tableMeta.value?.schema, tableName: tableMeta.value?.tableName || "table_name", columns: exportData.columns, + columnTypes: exportData.columnTypes, rows: exportData.rows, }); await saveTextFile(content, `${tableMeta.value?.tableName || "export"}.sql`, "SQL", "sql"); @@ -744,6 +752,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) { function sqlInsertExportData(result: { columns: string[]; rows: CellValue[][] }): { columns: string[]; + columnTypes?: Array; rows: CellValue[][]; } { const exportColumns = tableMeta.value ? effectiveColumns(sourceColumns.value, result.columns) : result.columns; @@ -752,6 +761,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) { .filter((item): item is { column: string; index: number } => !!item.column); return { columns: columnIndexes.map((item) => item.column), + columnTypes: tableMeta.value ? columnIndexes.map((item) => columnTypes.value?.[item.index]) : undefined, rows: result.rows.map((row) => columnIndexes.map((item) => row[item.index] ?? null)), }; } diff --git a/apps/desktop/src/lib/databaseExport.ts b/apps/desktop/src/lib/databaseExport.ts index fea9512e6..612008f82 100644 --- a/apps/desktop/src/lib/databaseExport.ts +++ b/apps/desktop/src/lib/databaseExport.ts @@ -15,6 +15,7 @@ export interface ExportedTableSql { qualifiedTableName?: string; ddl?: string; columns: string[]; + columnTypes?: Array; rows: QueryResult["rows"]; truncated?: boolean; } @@ -33,6 +34,7 @@ export interface BuildExportInsertStatementsOptions { tableName?: string; qualifiedTableName?: string; columns: string[]; + columnTypes?: Array; rows: QueryResult["rows"]; batchSize?: number; } diff --git a/apps/desktop/src/lib/exportFormats.ts b/apps/desktop/src/lib/exportFormats.ts index 45e4c5ce3..4634c1627 100644 --- a/apps/desktop/src/lib/exportFormats.ts +++ b/apps/desktop/src/lib/exportFormats.ts @@ -16,6 +16,7 @@ export interface FormatSqlInsertOptions { tableName?: string; qualifiedTableName?: string; columns: string[]; + columnTypes?: Array; rows: ExportCellValue[][]; } diff --git a/apps/desktop/src/lib/tauri.ts b/apps/desktop/src/lib/tauri.ts index 26a41a61e..3a3fe3d51 100644 --- a/apps/desktop/src/lib/tauri.ts +++ b/apps/desktop/src/lib/tauri.ts @@ -1566,6 +1566,7 @@ export interface TableExportRequest { filePath: string; format: "csv" | "xlsx" | "json" | "markdown" | "sql"; columns?: string[]; + columnTypes?: Array; primaryKeys?: string[]; whereInput?: string; orderBy?: string; diff --git a/crates/dbx-core/src/database_export.rs b/crates/dbx-core/src/database_export.rs index 47a643d90..86eab2611 100644 --- a/crates/dbx-core/src/database_export.rs +++ b/crates/dbx-core/src/database_export.rs @@ -72,6 +72,8 @@ pub struct ExportedTableSql { #[serde(default)] pub columns: Vec, #[serde(default)] + pub column_types: Vec>, + #[serde(default)] pub rows: Vec>, #[serde(default)] pub truncated: bool, @@ -91,6 +93,8 @@ pub struct BuildExportInsertStatementsOptions { #[serde(default)] pub columns: Vec, #[serde(default)] + pub column_types: Vec>, + #[serde(default)] pub rows: Vec>, #[serde(default, skip_serializing_if = "Option::is_none")] pub batch_size: Option, @@ -134,6 +138,54 @@ pub fn format_export_sql_literal(value: &Value) -> String { format!("'{}'", text.replace('\\', "\\\\").replace('\'', "''")) } +fn format_export_sql_literal_typed( + value: &Value, + database_type: Option, + column_type: Option<&str>, +) -> String { + if matches!(database_type, Some(DatabaseType::Mysql)) && column_type.is_some_and(is_mysql_bit_type) { + return format_mysql_bit_literal(value); + } + format_export_sql_literal(value) +} + +fn is_mysql_bit_type(column_type: &str) -> bool { + let trimmed = column_type.trim(); + let lower = trimmed.to_ascii_lowercase(); + lower == "bit" || lower.starts_with("bit(") || lower.starts_with("bit ") +} + +fn format_mysql_bit_literal(value: &Value) -> String { + match value { + Value::Null => "NULL".to_string(), + Value::Bool(value) => { + if *value { + "1".to_string() + } else { + "0".to_string() + } + } + Value::Number(value) => value.to_string(), + Value::String(value) => { + let trimmed = value.trim(); + if trimmed.eq_ignore_ascii_case("true") { + return "1".to_string(); + } + if trimmed.eq_ignore_ascii_case("false") { + return "0".to_string(); + } + if trimmed == "0" || trimmed == "1" { + return trimmed.to_string(); + } + if !trimmed.is_empty() && trimmed.bytes().all(|byte| byte == b'0' || byte == b'1') { + return format!("b'{trimmed}'"); + } + format!("'{}'", value.replace('\\', "\\\\").replace('\'', "''")) + } + other => format_export_sql_literal(other), + } +} + pub fn build_export_insert_statements(options: BuildExportInsertStatementsOptions) -> Result, String> { if options.columns.is_empty() || options.rows.is_empty() { return Ok(Vec::new()); @@ -157,7 +209,21 @@ pub fn build_export_insert_statements(options: BuildExportInsertStatementsOption for rows in options.rows.chunks(batch_size) { let values = rows .iter() - .map(|row| format!("({})", row.iter().map(format_export_sql_literal).collect::>().join(", "))) + .map(|row| { + let values = row + .iter() + .enumerate() + .map(|(index, value)| { + format_export_sql_literal_typed( + value, + options.database_type, + options.column_types.get(index).and_then(|value| value.as_deref()), + ) + }) + .collect::>() + .join(", "); + format!("({values})") + }) .collect::>() .join(", "); statements.push(format!("INSERT INTO {table} ({columns}) VALUES {values};")); @@ -203,6 +269,7 @@ pub fn build_database_sql_export(options: BuildDatabaseSqlExportOptions) -> Resu table_name: table.table_name, qualified_table_name: table.qualified_table_name, columns: table.columns, + column_types: table.column_types, rows: table.rows, batch_size: Some(insert_batch_size), })?; @@ -728,6 +795,7 @@ mod tests { table_name: Some("users".to_string()), qualified_table_name: None, columns: vec!["id".to_string(), "name".to_string()], + column_types: Vec::new(), rows: vec![vec![json!(1), json!("Ada")], vec![json!(2), json!("O'Hara")], vec![json!(3), json!("Linus")]], batch_size: Some(2), }) @@ -742,6 +810,26 @@ mod tests { ); } + #[test] + fn mysql_bit_columns_export_without_quoted_string_values() { + let statements = build_export_insert_statements(BuildExportInsertStatementsOptions { + database_type: Some(DatabaseType::Mysql), + schema: None, + table_name: Some("flags".to_string()), + qualified_table_name: None, + columns: vec!["enabled".to_string(), "mask".to_string(), "label".to_string()], + column_types: vec![Some("bit(1)".to_string()), Some("BIT(4)".to_string()), Some("varchar(20)".to_string())], + rows: vec![vec![json!("1"), json!("1010"), json!("1010")], vec![json!(false), json!(3), json!("off")]], + batch_size: Some(10), + }) + .unwrap(); + + assert_eq!( + statements, + vec!["INSERT INTO `flags` (`enabled`, `mask`, `label`) VALUES (1, b'1010', '1010'), (0, 3, 'off');"] + ); + } + #[test] fn builds_database_sql_export_with_ddl_before_data() { let sql = build_database_sql_export(BuildDatabaseSqlExportOptions { @@ -755,6 +843,7 @@ mod tests { qualified_table_name: None, ddl: Some("CREATE TABLE `users` (`id` int);".to_string()), columns: vec!["id".to_string()], + column_types: Vec::new(), rows: vec![vec![json!(1)]], truncated: true, }], diff --git a/crates/dbx-core/src/table_export.rs b/crates/dbx-core/src/table_export.rs index 292cb39f2..e0eaffb85 100644 --- a/crates/dbx-core/src/table_export.rs +++ b/crates/dbx-core/src/table_export.rs @@ -28,6 +28,8 @@ pub struct TableExportRequest { #[serde(default)] pub columns: Option>, #[serde(default)] + pub column_types: Option>>, + #[serde(default)] pub primary_keys: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub where_input: Option, @@ -164,9 +166,10 @@ pub async fn export_table_data_core( // 3. Resolve columns. Data grid exports can provide columns/primary keys // directly, which avoids expensive metadata round-trips on JDBC drivers. let requested_columns = request.columns.as_ref().filter(|columns| !columns.is_empty()); - let (col_names, primary_keys) = if let Some(requested_columns) = requested_columns { + let (col_names, column_types, primary_keys) = if let Some(requested_columns) = requested_columns { let primary_keys = request.primary_keys.clone().unwrap_or_default(); - (requested_columns.clone(), primary_keys) + let column_types = request.column_types.clone().unwrap_or_default(); + (requested_columns.clone(), column_types, primary_keys) } else { let columns = crate::schema::get_columns_core( state, @@ -177,8 +180,9 @@ pub async fn export_table_data_core( ) .await?; let col_names: Vec = columns.iter().map(|c| c.name.clone()).collect(); + let column_types: Vec> = columns.iter().map(|c| Some(c.data_type.clone())).collect(); let primary_keys: Vec = columns.iter().filter(|c| c.is_primary_key).map(|c| c.name.clone()).collect(); - (col_names, primary_keys) + (col_names, column_types, primary_keys) }; if col_names.is_empty() { @@ -567,6 +571,7 @@ pub async fn export_table_data_core( table_name: Some(request.table_name.clone()), qualified_table_name: None, columns: col_names.clone(), + column_types: column_types.clone(), rows: result.rows.clone(), batch_size: Some(100), })?;