fix(export): preserve MySQL bit literals in SQL export

This commit is contained in:
t8y2 2026-06-06 19:03:21 +08:00
parent f2924a3725
commit b226b445d6
9 changed files with 158 additions and 10 deletions

View File

@ -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<string, string>();
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),

View File

@ -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<string | undefined> {
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");

View File

@ -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<string | undefined> {
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);

View File

@ -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<string[]>;
displayItems: ComputedRef<RowItem[]>;
sql: ComputedRef<string | undefined>;
tableMeta: ComputedRef<{ schema?: string; tableName: string; primaryKeys: string[] } | undefined>;
tableMeta: ComputedRef<DataGridTableMeta | undefined>;
databaseType: ComputedRef<DatabaseType | undefined>;
connectionId: ComputedRef<string | undefined>;
database: ComputedRef<string | undefined>;
context: ComputedRef<"results" | "table-data" | undefined>;
sourceColumns: ComputedRef<Array<string | undefined> | undefined>;
columnTypes: ComputedRef<Array<string | undefined> | undefined>;
whereInput: ComputedRef<string | undefined>;
orderBy: ComputedRef<string | undefined>;
exportBatchSize: ComputedRef<number>;
@ -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<string | undefined>;
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)),
};
}

View File

@ -15,6 +15,7 @@ export interface ExportedTableSql {
qualifiedTableName?: string;
ddl?: string;
columns: string[];
columnTypes?: Array<string | null | undefined>;
rows: QueryResult["rows"];
truncated?: boolean;
}
@ -33,6 +34,7 @@ export interface BuildExportInsertStatementsOptions {
tableName?: string;
qualifiedTableName?: string;
columns: string[];
columnTypes?: Array<string | null | undefined>;
rows: QueryResult["rows"];
batchSize?: number;
}

View File

@ -16,6 +16,7 @@ export interface FormatSqlInsertOptions {
tableName?: string;
qualifiedTableName?: string;
columns: string[];
columnTypes?: Array<string | null | undefined>;
rows: ExportCellValue[][];
}

View File

@ -1566,6 +1566,7 @@ export interface TableExportRequest {
filePath: string;
format: "csv" | "xlsx" | "json" | "markdown" | "sql";
columns?: string[];
columnTypes?: Array<string | null | undefined>;
primaryKeys?: string[];
whereInput?: string;
orderBy?: string;

View File

@ -72,6 +72,8 @@ pub struct ExportedTableSql {
#[serde(default)]
pub columns: Vec<String>,
#[serde(default)]
pub column_types: Vec<Option<String>>,
#[serde(default)]
pub rows: Vec<Vec<Value>>,
#[serde(default)]
pub truncated: bool,
@ -91,6 +93,8 @@ pub struct BuildExportInsertStatementsOptions {
#[serde(default)]
pub columns: Vec<String>,
#[serde(default)]
pub column_types: Vec<Option<String>>,
#[serde(default)]
pub rows: Vec<Vec<Value>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub batch_size: Option<usize>,
@ -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<DatabaseType>,
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<Vec<String>, 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::<Vec<_>>().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::<Vec<_>>()
.join(", ");
format!("({values})")
})
.collect::<Vec<_>>()
.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,
}],

View File

@ -28,6 +28,8 @@ pub struct TableExportRequest {
#[serde(default)]
pub columns: Option<Vec<String>>,
#[serde(default)]
pub column_types: Option<Vec<Option<String>>>,
#[serde(default)]
pub primary_keys: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub where_input: Option<String>,
@ -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<String> = columns.iter().map(|c| c.name.clone()).collect();
let column_types: Vec<Option<String>> = columns.iter().map(|c| Some(c.data_type.clone())).collect();
let primary_keys: Vec<String> = 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),
})?;