fix(export): preserve MySQL bit literals in SQL export
This commit is contained in:
parent
f2924a3725
commit
b226b445d6
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ export interface FormatSqlInsertOptions {
|
|||
tableName?: string;
|
||||
qualifiedTableName?: string;
|
||||
columns: string[];
|
||||
columnTypes?: Array<string | null | undefined>;
|
||||
rows: ExportCellValue[][];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}],
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
})?;
|
||||
|
|
|
|||
Loading…
Reference in New Issue