diff --git a/apps/desktop/src/composables/useDataGridExport.ts b/apps/desktop/src/composables/useDataGridExport.ts index e8f7bdeac4..81291e05e2 100644 --- a/apps/desktop/src/composables/useDataGridExport.ts +++ b/apps/desktop/src/composables/useDataGridExport.ts @@ -426,6 +426,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) { } return buildDataGridCopyInsertStatement({ databaseType: databaseType.value, + identifierQuote: options.identifierQuote?.value, tableMeta: tableMeta.value, columns: data.columns, columnTypes: data.columnTypes, @@ -1250,6 +1251,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) { const exportData = sqlInsertExportData(result); const content = await formatSqlInsert({ databaseType: databaseType.value, + identifierQuote: options.identifierQuote?.value, schema: tableMeta.value?.schema, tableName: tableMeta.value?.tableName || "table_name", columns: exportData.columns, @@ -1271,6 +1273,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) { const exportData = sqlInsertExportData(result); const content = await formatSqlInsert({ databaseType: databaseType.value, + identifierQuote: options.identifierQuote?.value, schema: tableMeta.value?.schema, tableName: tableMeta.value?.tableName || "table_name", columns: exportData.columns, diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index c1b9605b5b..e2c1b4d361 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -4310,6 +4310,7 @@ export interface QueryResultExportRequest { exportColumnTypes?: Array; numericColumnRightAlign?: boolean; columnComments?: Array | null; + identifierQuote?: string; } export async function startTableExport(request: TableExportRequest, onProgress: (progress: TableExportProgress) => void): Promise { diff --git a/apps/desktop/src/lib/dataGrid/dataGridSql.ts b/apps/desktop/src/lib/dataGrid/dataGridSql.ts index 7b9cccdef2..df8adbc31e 100644 --- a/apps/desktop/src/lib/dataGrid/dataGridSql.ts +++ b/apps/desktop/src/lib/dataGrid/dataGridSql.ts @@ -36,6 +36,7 @@ export interface DataGridSaveStatementOptions { export interface DataGridCopyUpdateStatementOptions { databaseType?: DatabaseType; + identifierQuote?: string; tableMeta: DataGridTableMeta; columns: string[]; sourceColumns?: Array; @@ -46,6 +47,7 @@ export type DataGridCopyInsertMode = "merged" | "row-by-row"; export interface DataGridCopyInsertStatementOptions { databaseType?: DatabaseType; + identifierQuote?: string; tableMeta?: DataGridTableMeta; columns: string[]; columnTypes?: Array; diff --git a/apps/desktop/src/lib/export/exportFormats.ts b/apps/desktop/src/lib/export/exportFormats.ts index 006c209268..b759b1ef4f 100644 --- a/apps/desktop/src/lib/export/exportFormats.ts +++ b/apps/desktop/src/lib/export/exportFormats.ts @@ -29,6 +29,7 @@ export function formatTsv(columns: string[], rows: ExportCellValue[][]): string export interface FormatSqlInsertOptions { databaseType?: DatabaseType; + identifierQuote?: string; schema?: string; tableName?: string; qualifiedTableName?: string; diff --git a/apps/desktop/src/stores/__tests__/queryStore.queryResultExport.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.queryResultExport.spec.ts new file mode 100644 index 0000000000..4cb4217109 --- /dev/null +++ b/apps/desktop/src/stores/__tests__/queryStore.queryResultExport.spec.ts @@ -0,0 +1,79 @@ +import { createPinia, setActivePinia } from "pinia"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + ensureConnected: vi.fn(), + getConfig: vi.fn(), + connectionIdentifierQuote: vi.fn(), +})); + +vi.mock("@/lib/backend/api", () => ({ + saveOpenTabsState: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/stores/connectionStore", () => ({ + useConnectionStore: () => ({ + ensureConnected: mocks.ensureConnected, + getConfig: mocks.getConfig, + connectionIdentifierQuote: mocks.connectionIdentifierQuote, + }), +})); + +vi.mock("@/stores/settingsStore", () => ({ + useSettingsStore: () => ({ + editorSettings: { + exportBatchSize: 1000, + exportRowLimitEnabled: false, + exportRowLimit: 100_000, + globalQueryTimeoutSecs: 30, + queryExportKeysetOptimizationEnabled: true, + numericColumnRightAlign: true, + }, + }), +})); + +function installLocalStorage() { + const data = new Map(); + vi.stubGlobal("localStorage", { + getItem: vi.fn((key: string) => data.get(key) ?? null), + setItem: vi.fn((key: string, value: string) => data.set(key, value)), + removeItem: vi.fn((key: string) => data.delete(key)), + }); +} + +describe("queryStore query result export", () => { + beforeEach(() => { + vi.clearAllMocks(); + installLocalStorage(); + setActivePinia(createPinia()); + mocks.ensureConnected.mockResolvedValue(undefined); + mocks.getConfig.mockReturnValue({ id: "kingbase-1", name: "Kingbase", db_type: "kingbase", database: "app", query_timeout_secs: 30 }); + mocks.connectionIdentifierQuote.mockReturnValue("["); + }); + + it("passes the live connection identifier quote to backend SQL export", async () => { + const { useQueryStore } = await import("@/stores/queryStore"); + const store = useQueryStore(); + const tabId = store.createTab("kingbase-1", "app", "Query"); + const tab = store.tabs.find((item) => item.id === tabId)!; + tab.sql = "SELECT select FROM audit_log"; + tab.lastExecutedSql = tab.sql; + tab.result = { + columns: ["select"], + rows: [["value"]], + affected_rows: 0, + execution_time_ms: 1, + }; + + const request = await store.buildQueryResultExportRequest(tabId, { + exportId: "export-1", + filePath: "audit.sql", + format: "sql", + exportTableName: "audit_log", + exportColumnTypes: ["text"], + }); + + expect(request?.identifierQuote).toBe("["); + expect(mocks.connectionIdentifierQuote).toHaveBeenCalledWith("kingbase-1"); + }); +}); diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index ac969eefd3..366c73e3de 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -6084,6 +6084,7 @@ export const useQueryStore = defineStore("query", () => { exportTableName: options.exportTableName, exportColumnTypes: options.exportColumnTypes, numericColumnRightAlign: settings.numericColumnRightAlign, + identifierQuote: connStore.connectionIdentifierQuote(location.connectionId), }; } @@ -6119,6 +6120,7 @@ export const useQueryStore = defineStore("query", () => { executionId: uuid(), numericColumnRightAlign: settings.numericColumnRightAlign, columnComments, + identifierQuote: connStore.connectionIdentifierQuote(tab.connectionId), }; const tracker = useExportTracker(); diff --git a/crates/dbx-core/src/data_grid_extractors/sql.rs b/crates/dbx-core/src/data_grid_extractors/sql.rs index aced2d086d..6b9f994a26 100644 --- a/crates/dbx-core/src/data_grid_extractors/sql.rs +++ b/crates/dbx-core/src/data_grid_extractors/sql.rs @@ -48,6 +48,7 @@ pub(super) fn write_sql_inserts( let data = sql_selected_data(context, false)?; let statement = build_data_grid_copy_insert_statement(DataGridCopyInsertStatementOptions { database_type: context.request.database_type, + identifier_quote: context.request.identifier_quote.clone(), table_meta: context.request.table_meta.clone(), columns: data.columns, column_types: Some(data.column_types), @@ -98,6 +99,7 @@ pub(super) fn write_sql_updates( } let statements = build_data_grid_copy_update_statements(DataGridCopyUpdateStatementOptions { database_type: context.request.database_type, + identifier_quote: context.request.identifier_quote.clone(), table_meta: table_meta.clone(), columns: data.columns, source_columns: Some(data.source_columns), @@ -301,7 +303,7 @@ pub(super) fn write_where_clause( value, context.selected_column_info[column_index], true, - None, + context.request.identifier_quote.as_deref(), ); write_bytes(output, predicate.as_bytes())?; } diff --git a/crates/dbx-core/src/data_grid_extractors/tests.rs b/crates/dbx-core/src/data_grid_extractors/tests.rs index 6e7ca82755..5ddbd849a1 100644 --- a/crates/dbx-core/src/data_grid_extractors/tests.rs +++ b/crates/dbx-core/src/data_grid_extractors/tests.rs @@ -246,6 +246,53 @@ fn builds_updates_with_hidden_primary_keys_and_selected_columns() { ); } +#[test] +fn sql_copy_honors_kingbase_mysql_compat_connection_identifier_quote() { + let table_meta = DataGridTableMeta { + catalog: None, + database: None, + schema: Some("audit-schema".to_string()), + table_name: "events".to_string(), + primary_keys: vec!["id".to_string()], + columns: Some(vec![ + DataGridColumnInfo { + name: "id".to_string(), + data_type: "int".to_string(), + is_nullable: false, + is_primary_key: true, + column_default: None, + extra: None, + }, + DataGridColumnInfo { + name: "event_type".to_string(), + data_type: "varchar".to_string(), + is_nullable: false, + is_primary_key: false, + column_default: None, + extra: None, + }, + ]), + }; + + let mut insert = request(DataGridExtractorId::SqlInserts); + insert.database_type = Some(DatabaseType::Kingbase); + insert.identifier_quote = Some("`".to_string()); + insert.table_meta = Some(table_meta.clone()); + insert.columns = vec![column("id", 0), column("event_type", 1)]; + insert.rows = vec![vec![json!(1), json!("login")]]; + let insert_result = extract_data_grid_selection(insert).expect("Kingbase SQL INSERT extraction"); + assert_eq!(insert_result.text, "INSERT INTO `audit-schema`.`events` (`id`, `event_type`) VALUES (1, 'login');"); + + let mut updates = request(DataGridExtractorId::SqlUpdates); + updates.database_type = Some(DatabaseType::Kingbase); + updates.identifier_quote = Some("`".to_string()); + updates.table_meta = Some(table_meta); + updates.columns = vec![column("id", 0), column("event_type", 1)]; + updates.rows = vec![vec![json!(1), json!("logout")]]; + let updates_result = extract_data_grid_selection(updates).expect("Kingbase SQL UPDATE extraction"); + assert_eq!(updates_result.text, "UPDATE `audit-schema`.`events` SET `event_type` = 'logout' WHERE `id` = 1;"); +} + #[test] fn sql_update_computed_column_option_matches_the_frontend_capability() { let mut request = request(DataGridExtractorId::SqlUpdates); @@ -595,6 +642,7 @@ fn build_data_grid_copy_update_statements_returns_empty_for_mongodb() { use crate::data_grid_sql::{build_data_grid_copy_update_statements, DataGridCopyUpdateStatementOptions}; let options = DataGridCopyUpdateStatementOptions { database_type: Some(DatabaseType::MongoDb), + identifier_quote: None, table_meta: DataGridTableMeta { catalog: None, database: None, diff --git a/crates/dbx-core/src/data_grid_sql.rs b/crates/dbx-core/src/data_grid_sql.rs index 2819aea6d8..9f7708f45a 100644 --- a/crates/dbx-core/src/data_grid_sql.rs +++ b/crates/dbx-core/src/data_grid_sql.rs @@ -90,6 +90,8 @@ pub struct DataGridSaveStatementOptions { pub struct DataGridCopyUpdateStatementOptions { #[serde(default, skip_serializing_if = "Option::is_none")] pub database_type: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub identifier_quote: Option, pub table_meta: DataGridTableMeta, pub columns: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -104,6 +106,8 @@ pub struct DataGridCopyInsertStatementOptions { #[serde(default, skip_serializing_if = "Option::is_none")] pub database_type: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub identifier_quote: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub table_meta: Option, pub columns: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -362,7 +366,7 @@ pub fn build_data_grid_copy_update_statements(options: DataGridCopyUpdateStateme options.table_meta.schema.as_deref(), options.table_meta.database.as_deref(), &options.table_meta.table_name, - None, + options.identifier_quote.as_deref(), ); let mut statements = Vec::new(); for row in &options.rows { @@ -374,7 +378,7 @@ pub fn build_data_grid_copy_update_statements(options: DataGridCopyUpdateStateme .map(|(column, index, info)| { format!( "{} = {}", - data_grid_identifier(options.database_type, column, None), + data_grid_identifier(options.database_type, column, options.identifier_quote.as_deref()), format_grid_sql_literal(row.get(*index).unwrap_or(&Value::Null), options.database_type, *info) ) }) @@ -393,7 +397,7 @@ pub fn build_data_grid_copy_update_statements(options: DataGridCopyUpdateStateme row.get(primary_key_indexes[index]).unwrap_or(&Value::Null), primary_key_info[index], false, - None, + options.identifier_quote.as_deref(), ) }) .collect::>() @@ -447,18 +451,19 @@ pub fn build_data_grid_copy_insert_statement(options: DataGridCopyInsertStatemen let table = options.table_meta.as_ref().map_or_else( || "table_name".to_string(), |meta| { - crate::sql_dialect::qualified_table_name_with_catalog( + data_grid_qualified_table_name( options.database_type, meta.catalog.as_deref(), meta.schema.as_deref(), meta.database.as_deref(), &meta.table_name, + options.identifier_quote.as_deref(), ) }, ); let columns = insert_columns .iter() - .map(|(column, _, _)| quote_ident(options.database_type, column)) + .map(|(column, _, _)| data_grid_identifier(options.database_type, column, options.identifier_quote.as_deref())) .collect::>() .join(", "); let value_rows = options @@ -3233,6 +3238,7 @@ mod tests { fn builds_copy_update_statements() { let statements = build_data_grid_copy_update_statements(DataGridCopyUpdateStatementOptions { database_type: Some(DatabaseType::Postgres), + identifier_quote: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -3255,6 +3261,7 @@ mod tests { fn builds_copy_insert_statement_without_primary_keys() { let statement = build_data_grid_copy_insert_statement(DataGridCopyInsertStatementOptions { database_type: Some(DatabaseType::Mysql), + identifier_quote: None, table_meta: Some(DataGridTableMeta { catalog: None, database: None, @@ -3281,6 +3288,7 @@ mod tests { fn copy_insert_keeps_json_cells_as_single_json_literals() { let statement = build_data_grid_copy_insert_statement(DataGridCopyInsertStatementOptions { database_type: Some(DatabaseType::Elasticsearch), + identifier_quote: None, table_meta: None, columns: vec!["id".to_string(), "active".to_string(), "profile".to_string()], column_types: Some(vec![Some("number".to_string()), Some("boolean".to_string()), Some("json".to_string())]), @@ -3301,6 +3309,7 @@ mod tests { fn copy_insert_keeps_mysql_json_array_as_single_json_literal() { let statement = build_data_grid_copy_insert_statement(DataGridCopyInsertStatementOptions { database_type: Some(DatabaseType::Mysql), + identifier_quote: None, table_meta: None, columns: vec!["data".to_string()], column_types: Some(vec![Some("json".to_string())]), @@ -3318,6 +3327,7 @@ mod tests { fn copy_insert_keeps_mysql_json_empty_array_as_single_json_literal() { let statement = build_data_grid_copy_insert_statement(DataGridCopyInsertStatementOptions { database_type: Some(DatabaseType::Mysql), + identifier_quote: None, table_meta: None, columns: vec!["data".to_string()], column_types: Some(vec![Some("json".to_string())]), @@ -3335,6 +3345,7 @@ mod tests { fn copy_insert_keeps_mysql_json_object_as_single_json_literal() { let statement = build_data_grid_copy_insert_statement(DataGridCopyInsertStatementOptions { database_type: Some(DatabaseType::Mysql), + identifier_quote: None, table_meta: None, columns: vec!["data".to_string()], column_types: Some(vec![Some("json".to_string())]), @@ -3352,6 +3363,7 @@ mod tests { fn builds_copy_insert_without_primary_keys_when_primary_keys_are_hidden() { let statement = build_data_grid_copy_insert_statement(DataGridCopyInsertStatementOptions { database_type: Some(DatabaseType::Mysql), + identifier_quote: None, table_meta: Some(DataGridTableMeta { catalog: None, database: None, @@ -3379,6 +3391,7 @@ mod tests { fn builds_copy_insert_statement_row_by_row() { let statement = build_data_grid_copy_insert_statement(DataGridCopyInsertStatementOptions { database_type: Some(DatabaseType::Mysql), + identifier_quote: None, table_meta: Some(DataGridTableMeta { catalog: None, database: None, @@ -3407,6 +3420,7 @@ mod tests { fn oracle_copy_insert_statement_uses_one_statement_per_row() { let statement = build_data_grid_copy_insert_statement(DataGridCopyInsertStatementOptions { database_type: Some(DatabaseType::Oracle), + identifier_quote: None, table_meta: Some(DataGridTableMeta { catalog: None, database: None, @@ -3445,6 +3459,7 @@ mod tests { let insert = build_data_grid_copy_insert_statement(DataGridCopyInsertStatementOptions { database_type: Some(DatabaseType::Mysql), + identifier_quote: None, table_meta: Some(table_meta.clone()), columns: columns.clone(), column_types: None, @@ -3461,6 +3476,7 @@ mod tests { let updates = build_data_grid_copy_update_statements(DataGridCopyUpdateStatementOptions { database_type: Some(DatabaseType::Mysql), + identifier_quote: None, table_meta, columns, source_columns: None, @@ -3499,6 +3515,7 @@ mod tests { fn builds_copy_insert_statement_omits_postgres_tsvector_columns() { let statement = build_data_grid_copy_insert_statement(DataGridCopyInsertStatementOptions { database_type: Some(DatabaseType::Postgres), + identifier_quote: None, table_meta: Some(DataGridTableMeta { catalog: None, database: None, @@ -3530,6 +3547,7 @@ mod tests { fn oracle_copy_insert_uses_result_column_types_for_date_literals() { let statement = build_data_grid_copy_insert_statement(DataGridCopyInsertStatementOptions { database_type: Some(DatabaseType::Oracle), + identifier_quote: None, table_meta: None, columns: vec!["ID".to_string(), "CREATED_ON".to_string(), "RAW_TEXT".to_string()], column_types: Some(vec![ @@ -5032,6 +5050,56 @@ mod tests { .all(|statement| statement.contains("`gc`.`docfileinfo`") && !statement.contains('"'))); } + #[test] + fn kingbase_mysql_compat_copy_insert_uses_backtick_identifiers() { + let statement = build_data_grid_copy_insert_statement(DataGridCopyInsertStatementOptions { + database_type: Some(DatabaseType::Kingbase), + identifier_quote: Some("`".to_string()), + table_meta: Some(DataGridTableMeta { + catalog: None, + database: None, + schema: Some("audit-schema".to_string()), + table_name: "events".to_string(), + primary_keys: vec!["id".to_string()], + columns: None, + }), + columns: vec!["id".to_string(), "event_type".to_string()], + column_types: None, + source_columns: None, + rows: vec![vec![json!(1), json!("login")]], + exclude_primary_keys: false, + include_computed_columns: false, + insert_mode: DataGridCopyInsertMode::Merged, + }); + + assert_eq!( + statement.as_deref(), + Some("INSERT INTO `audit-schema`.`events` (`id`, `event_type`) VALUES (1, 'login');") + ); + assert!(!statement.as_deref().unwrap_or_default().contains('"')); + } + + #[test] + fn kingbase_mysql_compat_copy_update_uses_backtick_identifiers() { + let statements = build_data_grid_copy_update_statements(DataGridCopyUpdateStatementOptions { + database_type: Some(DatabaseType::Kingbase), + identifier_quote: Some("`".to_string()), + table_meta: DataGridTableMeta { + catalog: None, + database: None, + schema: Some("audit-schema".to_string()), + table_name: "events".to_string(), + primary_keys: vec!["id".to_string()], + columns: None, + }, + columns: vec!["id".to_string(), "event_type".to_string()], + source_columns: None, + rows: vec![vec![json!(1), json!("logout")]], + }); + + assert_eq!(statements, vec!["UPDATE `audit-schema`.`events` SET `event_type` = 'logout' WHERE `id` = 1;"]); + } + #[test] fn vastbase_query_result_writes_use_resolved_search_path_schema() { let result = prepare_data_grid_save(DataGridSaveStatementOptions { @@ -6419,6 +6487,7 @@ mod tests { fn builds_clickhouse_copy_update_statements() { let statements = build_data_grid_copy_update_statements(DataGridCopyUpdateStatementOptions { database_type: Some(DatabaseType::ClickHouse), + identifier_quote: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -6448,6 +6517,7 @@ mod tests { let copy_updates = build_data_grid_copy_update_statements(DataGridCopyUpdateStatementOptions { database_type: Some(DatabaseType::Doris), + identifier_quote: None, table_meta: table_meta.clone(), columns: vec!["id".to_string(), "status".to_string()], source_columns: None, @@ -6460,6 +6530,7 @@ mod tests { let copy_insert = build_data_grid_copy_insert_statement(DataGridCopyInsertStatementOptions { database_type: Some(DatabaseType::Doris), + identifier_quote: None, table_meta: Some(table_meta.clone()), columns: vec!["id".to_string(), "status".to_string()], column_types: None, diff --git a/crates/dbx-core/src/database_export.rs b/crates/dbx-core/src/database_export.rs index ff1e802106..54b3ac3eee 100644 --- a/crates/dbx-core/src/database_export.rs +++ b/crates/dbx-core/src/database_export.rs @@ -8,7 +8,7 @@ use crate::connection::task_client_session_id; use crate::models::connection::DatabaseType; use crate::mysql_ddl_normalize::DdlNormalizeOptions; use crate::object_source_sql::build_export_object_source_sql; -use crate::sql_dialect::{qualified_table_name, quote_table_identifier, uses_single_row_insert_statements}; +use crate::sql_dialect::{qualified_table_name, uses_single_row_insert_statements}; use crate::transfer::{ format_ch_array_sql_literal, format_pg_array_sql_literal, is_identity_column_extra, is_mysql_generated_column_extra, keyset_pagination_sql_with_identifier_quote, quote_identifier, @@ -266,6 +266,8 @@ pub struct ExportedTableSql { #[serde(default, skip_serializing_if = "Option::is_none")] pub database_type: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub identifier_quote: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub schema: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub table_name: Option, @@ -291,6 +293,8 @@ pub struct BuildExportInsertStatementsOptions { #[serde(default, skip_serializing_if = "Option::is_none")] pub database_type: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub identifier_quote: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub schema: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub table_name: Option, @@ -935,6 +939,7 @@ pub fn build_export_insert_statements(options: BuildExportInsertStatementsOption options.schema.as_deref(), options.table_name.as_deref(), options.qualified_table_name.as_deref(), + options.identifier_quote.as_deref(), )?; let insert_columns = options .columns @@ -970,7 +975,13 @@ pub fn build_export_insert_statements(options: BuildExportInsertStatementsOption }; let columns = insert_columns .iter() - .map(|(_, column, _)| quote_table_identifier(options.database_type, column)) + .map(|(_, column, _)| { + crate::sql_dialect::quote_table_data_identifier( + options.database_type, + column, + options.identifier_quote.as_deref(), + ) + }) .collect::>() .join(", "); let mut statements = Vec::new(); @@ -1131,6 +1142,7 @@ pub fn build_database_sql_export(options: BuildDatabaseSqlExportOptions) -> Resu let inserts = build_export_insert_statements(BuildExportInsertStatementsOptions { database_type: table.database_type, + identifier_quote: table.identifier_quote.clone(), schema: table.schema, table_name: table.table_name, qualified_table_name: table.qualified_table_name, @@ -1156,6 +1168,7 @@ fn export_qualified_table_name( schema: Option<&str>, table_name: Option<&str>, qualified_name: Option<&str>, + identifier_quote: Option<&str>, ) -> Result { if let Some(name) = qualified_name.filter(|name| !name.trim().is_empty()) { return Ok(name.to_string()); @@ -1163,6 +1176,14 @@ fn export_qualified_table_name( let table_name = table_name .filter(|name| !name.trim().is_empty()) .ok_or_else(|| "tableName is required when qualifiedTableName is not provided".to_string())?; + if crate::sql_dialect::uses_connection_identifier_quote(database_type, identifier_quote) { + return Ok(crate::sql_dialect::table_data_qualified_table_name( + database_type, + schema, + table_name, + identifier_quote, + )); + } Ok(qualified_table_name(database_type, schema, table_name)) } @@ -1591,8 +1612,12 @@ fn write_database_export_rows( // path and the legacy typed INSERT writer. In particular, MySQL uses the // selected database rather than a schema-qualified table name. let qualified_table_name = crate::transfer::qualified_table(table, schema, db_type, None); + // Batch database exports do not currently thread a per-connection identifier + // quote through BuildDatabaseSqlExportOptions; Kingbase MySQL-compat users + // should fall back to the single-table export path which carries the quote. let statements = build_export_insert_statements(BuildExportInsertStatementsOptions { database_type: Some(*db_type), + identifier_quote: None, schema: (!schema.is_empty()).then(|| schema.to_string()), table_name: Some(table.to_string()), qualified_table_name: Some(qualified_table_name), @@ -3052,6 +3077,7 @@ mod tests { fn mysql_spatial_export_uses_wkb_constructor_and_preserves_srid() { let statements = build_export_insert_statements(BuildExportInsertStatementsOptions { database_type: Some(DatabaseType::Mysql), + identifier_quote: None, schema: None, table_name: Some("places".to_string()), qualified_table_name: None, @@ -3129,6 +3155,7 @@ mod tests { fn database_specific_boolean_export_literals() { let sqlserver_statements = build_export_insert_statements(BuildExportInsertStatementsOptions { database_type: Some(DatabaseType::SqlServer), + identifier_quote: None, schema: Some("dbo".to_string()), table_name: Some("flags".to_string()), qualified_table_name: None, @@ -3141,6 +3168,7 @@ mod tests { .unwrap(); let postgres_statements = build_export_insert_statements(BuildExportInsertStatementsOptions { database_type: Some(DatabaseType::Postgres), + identifier_quote: None, schema: Some("public".to_string()), table_name: Some("flags".to_string()), qualified_table_name: None, @@ -3168,6 +3196,7 @@ mod tests { fn sqlserver_export_prefixes_unicode_string_literals() { let statements = build_export_insert_statements(BuildExportInsertStatementsOptions { database_type: Some(DatabaseType::SqlServer), + identifier_quote: None, schema: Some("dbo".to_string()), table_name: Some("people".to_string()), qualified_table_name: None, @@ -3212,6 +3241,7 @@ mod tests { fn mysql_export_inserts_escape_control_characters() { let statements = build_export_insert_statements(BuildExportInsertStatementsOptions { database_type: Some(DatabaseType::Mysql), + identifier_quote: None, schema: None, table_name: Some("notes".to_string()), qualified_table_name: None, @@ -3234,6 +3264,7 @@ mod tests { let long_value = "x".repeat(300_000); let statements = build_export_insert_statements(BuildExportInsertStatementsOptions { database_type: Some(DatabaseType::Mysql), + identifier_quote: None, schema: None, table_name: Some("payloads".to_string()), qualified_table_name: None, @@ -3253,6 +3284,7 @@ mod tests { fn sqlserver_export_caps_multi_row_insert_at_1000_rows() { let statements = build_export_insert_statements(BuildExportInsertStatementsOptions { database_type: Some(DatabaseType::SqlServer), + identifier_quote: None, schema: Some("dbo".to_string()), table_name: Some("items".to_string()), qualified_table_name: None, @@ -3273,6 +3305,7 @@ mod tests { fn doris_export_inserts_escape_control_characters() { let statements = build_export_insert_statements(BuildExportInsertStatementsOptions { database_type: Some(DatabaseType::Doris), + identifier_quote: None, schema: Some("warehouse".to_string()), table_name: Some("events".to_string()), qualified_table_name: None, @@ -3291,6 +3324,7 @@ mod tests { fn postgres_export_inserts_escape_control_characters() { let statements = build_export_insert_statements(BuildExportInsertStatementsOptions { database_type: Some(DatabaseType::Postgres), + identifier_quote: None, schema: Some("public".to_string()), table_name: Some("notes".to_string()), qualified_table_name: None, @@ -3309,6 +3343,7 @@ mod tests { fn postgres_export_inserts_escape_quotes_and_backslashes_without_changing_plain_strings() { let statements = build_export_insert_statements(BuildExportInsertStatementsOptions { database_type: Some(DatabaseType::Postgres), + identifier_quote: None, schema: Some("public".to_string()), table_name: Some("notes".to_string()), qualified_table_name: None, @@ -3337,6 +3372,7 @@ mod tests { fn postgres_jsonb_export_preserves_json_escape_sequences() { let statements = build_export_insert_statements(BuildExportInsertStatementsOptions { database_type: Some(DatabaseType::Postgres), + identifier_quote: None, schema: Some("public".to_string()), table_name: Some("events".to_string()), qualified_table_name: None, @@ -3360,6 +3396,7 @@ mod tests { fn postgres_vector_export_preserves_pgvector_bracket_literals() { let statements = build_export_insert_statements(BuildExportInsertStatementsOptions { database_type: Some(DatabaseType::Postgres), + identifier_quote: None, schema: Some("public".to_string()), table_name: Some("items".to_string()), qualified_table_name: None, @@ -3393,6 +3430,7 @@ mod tests { fn builds_batched_insert_statements_for_export() { let statements = build_export_insert_statements(BuildExportInsertStatementsOptions { database_type: Some(DatabaseType::Mysql), + identifier_quote: None, schema: None, table_name: Some("users".to_string()), qualified_table_name: None, @@ -3417,6 +3455,7 @@ mod tests { fn oracle_export_inserts_use_one_statement_per_row() { let statements = build_export_insert_statements(BuildExportInsertStatementsOptions { database_type: Some(DatabaseType::Oracle), + identifier_quote: None, schema: Some("APP".to_string()), table_name: Some("USERS".to_string()), qualified_table_name: None, @@ -3441,6 +3480,7 @@ mod tests { fn oracle_export_omits_synthetic_rowid_from_insert_columns() { let statements = build_export_insert_statements(BuildExportInsertStatementsOptions { database_type: Some(DatabaseType::Oracle), + identifier_quote: None, schema: Some("APP".to_string()), table_name: Some("USERS".to_string()), qualified_table_name: None, @@ -3459,6 +3499,7 @@ mod tests { fn oceanbase_oracle_export_omits_synthetic_rowid_from_insert_columns() { let statements = build_export_insert_statements(BuildExportInsertStatementsOptions { database_type: Some(DatabaseType::OceanbaseOracle), + identifier_quote: None, schema: Some("APP".to_string()), table_name: Some("USERS".to_string()), qualified_table_name: None, @@ -3477,6 +3518,7 @@ mod tests { fn non_oracle_export_preserves_dbx_rowid_named_column() { let statements = build_export_insert_statements(BuildExportInsertStatementsOptions { database_type: Some(DatabaseType::Mysql), + identifier_quote: None, schema: None, table_name: Some("users".to_string()), qualified_table_name: None, @@ -3495,6 +3537,7 @@ mod tests { fn oracle_date_columns_export_as_date_literals() { let statements = build_export_insert_statements(BuildExportInsertStatementsOptions { database_type: Some(DatabaseType::Oracle), + identifier_quote: None, schema: Some("APP".to_string()), table_name: Some("EVENTS".to_string()), qualified_table_name: None, @@ -3523,6 +3566,7 @@ mod tests { for database_type in [DatabaseType::Oracle, DatabaseType::OceanbaseOracle] { let statements = build_export_insert_statements(BuildExportInsertStatementsOptions { database_type: Some(database_type), + identifier_quote: None, schema: Some("APP".to_string()), table_name: Some("EVENTS".to_string()), qualified_table_name: None, @@ -3594,6 +3638,7 @@ mod tests { fn non_oracle_timestamp_exports_keep_existing_literals() { let statements = build_export_insert_statements(BuildExportInsertStatementsOptions { database_type: Some(DatabaseType::Postgres), + identifier_quote: None, schema: Some("public".to_string()), table_name: Some("events".to_string()), qualified_table_name: None, @@ -3618,6 +3663,7 @@ mod tests { fn mysql_bit_columns_export_without_quoted_string_values() { let statements = build_export_insert_statements(BuildExportInsertStatementsOptions { database_type: Some(DatabaseType::Mysql), + identifier_quote: None, schema: None, table_name: Some("flags".to_string()), qualified_table_name: None, @@ -3639,6 +3685,7 @@ mod tests { fn dameng_bit_columns_export_as_numeric_literals() { let statements = build_export_insert_statements(BuildExportInsertStatementsOptions { database_type: Some(DatabaseType::Dameng), + identifier_quote: None, schema: Some("DBX_TEST".to_string()), table_name: Some("FLAGS".to_string()), qualified_table_name: None, @@ -3660,6 +3707,7 @@ mod tests { fn dameng_strings_export_nul_as_chr_expression() { let statements = build_export_insert_statements(BuildExportInsertStatementsOptions { database_type: Some(DatabaseType::Dameng), + identifier_quote: None, schema: Some("DBX_TEST".to_string()), table_name: Some("NUL_VALUES".to_string()), qualified_table_name: None, @@ -3701,6 +3749,7 @@ mod tests { fn mysql_export_uses_typed_literals_for_numeric_and_blob_columns() { let statements = build_export_insert_statements(BuildExportInsertStatementsOptions { database_type: Some(DatabaseType::Mysql), + identifier_quote: None, schema: None, table_name: Some("t_test_01".to_string()), qualified_table_name: None, @@ -3727,6 +3776,7 @@ mod tests { fn temporal_columns_export_without_rfc3339_separator_or_utc_suffix() { let statements = build_export_insert_statements(BuildExportInsertStatementsOptions { database_type: Some(DatabaseType::Mysql), + identifier_quote: None, schema: None, table_name: Some("events".to_string()), qualified_table_name: None, @@ -3760,6 +3810,7 @@ mod tests { fn postgres_timestamptz_export_keeps_timezone_without_rfc3339_t_separator() { let statements = build_export_insert_statements(BuildExportInsertStatementsOptions { database_type: Some(DatabaseType::Postgres), + identifier_quote: None, schema: Some("public".to_string()), table_name: Some("events".to_string()), qualified_table_name: None, @@ -3786,6 +3837,7 @@ mod tests { fn sqlserver_rowversion_timestamp_type_is_not_treated_as_datetime() { let statements = build_export_insert_statements(BuildExportInsertStatementsOptions { database_type: Some(DatabaseType::SqlServer), + identifier_quote: None, schema: Some("dbo".to_string()), table_name: Some("events".to_string()), qualified_table_name: None, @@ -3809,6 +3861,7 @@ mod tests { fn postgres_tsvector_columns_are_omitted_from_sql_insert_export() { let statements = build_export_insert_statements(BuildExportInsertStatementsOptions { database_type: Some(DatabaseType::Postgres), + identifier_quote: None, schema: Some("public".to_string()), table_name: Some("articles".to_string()), qualified_table_name: None, @@ -3832,6 +3885,7 @@ mod tests { tables: vec![ExportedTableSql { display_name: "orders".to_string(), database_type: Some(DatabaseType::Mysql), + identifier_quote: None, schema: None, table_name: Some("orders".to_string()), qualified_table_name: None, @@ -3918,6 +3972,7 @@ mod tests { fn dameng_identity_export_inserts_enable_identity_insert() { let statements = build_export_insert_statements(BuildExportInsertStatementsOptions { database_type: Some(DatabaseType::Dameng), + identifier_quote: None, schema: Some("SYSDBA".to_string()), table_name: Some("USERS".to_string()), qualified_table_name: None, @@ -3945,6 +4000,7 @@ mod tests { tables: vec![ExportedTableSql { display_name: "users".to_string(), database_type: Some(DatabaseType::Mysql), + identifier_quote: None, schema: None, table_name: Some("users".to_string()), qualified_table_name: None, @@ -4296,4 +4352,22 @@ mod tests { let _ = std::fs::remove_dir_all(&scratch); } + + #[test] + fn kingbase_mysql_compat_export_insert_uses_backtick_identifiers() { + let statements = build_export_insert_statements(BuildExportInsertStatementsOptions { + database_type: Some(DatabaseType::Kingbase), + identifier_quote: Some("`".to_string()), + schema: Some("audit-schema".to_string()), + table_name: Some("events".to_string()), + qualified_table_name: None, + columns: vec!["id".to_string(), "event_type".to_string()], + column_types: vec![None, None], + column_extras: vec![None, None], + rows: vec![vec![json!(1), json!("login")]], + batch_size: Some(100), + }) + .unwrap(); + assert_eq!(statements, vec!["INSERT INTO `audit-schema`.`events` (`id`, `event_type`) VALUES (1, 'login');"]); + } } diff --git a/crates/dbx-core/src/query_result_export.rs b/crates/dbx-core/src/query_result_export.rs index 1df20bd87c..9f6449daad 100644 --- a/crates/dbx-core/src/query_result_export.rs +++ b/crates/dbx-core/src/query_result_export.rs @@ -104,6 +104,8 @@ pub struct QueryResultExportRequest { pub numeric_column_right_align: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub column_comments: Option>>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub identifier_quote: Option, } pub struct StagedExportTarget { @@ -300,6 +302,7 @@ struct SqlInsertWriter { database_type: DatabaseType, schema: Option, table_name: String, + identifier_quote: Option, } impl SqlInsertWriter { @@ -325,6 +328,7 @@ impl SqlInsertWriter { database_type: request.database_type, schema: request.schema.clone(), table_name, + identifier_quote: request.identifier_quote.clone(), }) } @@ -355,6 +359,7 @@ impl SqlInsertWriter { } let stmts = build_export_insert_statements(BuildExportInsertStatementsOptions { database_type: Some(self.database_type), + identifier_quote: self.identifier_quote.clone(), schema: self.schema.clone(), table_name: Some(self.table_name.clone()), qualified_table_name: None, @@ -1869,6 +1874,7 @@ mod tests { export_column_types: None, numeric_column_right_align: false, column_comments: None, + identifier_quote: None, } } diff --git a/crates/dbx-core/src/table_export.rs b/crates/dbx-core/src/table_export.rs index bea7c0069a..a566fc8002 100644 --- a/crates/dbx-core/src/table_export.rs +++ b/crates/dbx-core/src/table_export.rs @@ -1179,6 +1179,7 @@ async fn try_export_native_table_stream( } let statements = build_export_insert_statements(BuildExportInsertStatementsOptions { database_type: Some(*db_type), + identifier_quote: request.identifier_quote.clone(), schema: request.schema.clone(), table_name: Some(request.table_name.clone()), qualified_table_name: None, @@ -1993,6 +1994,7 @@ async fn export_table_data_core_inner( let statements = build_export_insert_statements(BuildExportInsertStatementsOptions { database_type: Some(db_type), + identifier_quote: request.identifier_quote.clone(), schema: request.schema.clone(), table_name: Some(request.table_name.clone()), qualified_table_name: None, @@ -2761,6 +2763,7 @@ mod tests { let statements = build_export_insert_statements(BuildExportInsertStatementsOptions { database_type: Some(DatabaseType::Oracle), + identifier_quote: request.identifier_quote, schema: request.schema, table_name: Some(request.table_name), qualified_table_name: None, diff --git a/crates/dbx-core/tests/live_clickhouse_query_result_export.rs b/crates/dbx-core/tests/live_clickhouse_query_result_export.rs index 71fac6397f..4221f3df1e 100644 --- a/crates/dbx-core/tests/live_clickhouse_query_result_export.rs +++ b/crates/dbx-core/tests/live_clickhouse_query_result_export.rs @@ -104,6 +104,7 @@ async fn live_clickhouse_query_result_export_xlsx_streams_random_order_query_onc export_table_name: None, export_column_types: None, column_comments: None, + identifier_quote: None, numeric_column_right_align: false, }; let done_seen = AtomicBool::new(false); diff --git a/crates/dbx-core/tests/live_mysql57.rs b/crates/dbx-core/tests/live_mysql57.rs index e86d41965c..fc7c00a0b3 100644 --- a/crates/dbx-core/tests/live_mysql57.rs +++ b/crates/dbx-core/tests/live_mysql57.rs @@ -450,6 +450,7 @@ async fn live_mysql_query_result_export_xlsx_streams_single_query_without_duplic export_table_name: None, export_column_types: None, column_comments: None, + identifier_quote: None, numeric_column_right_align: false, }; let done_seen = AtomicBool::new(false); @@ -540,6 +541,7 @@ async fn live_mysql_xlsx_export_can_outlive_query_timeout_while_rows_keep_arrivi export_table_name: None, export_column_types: None, column_comments: None, + identifier_quote: None, numeric_column_right_align: false, }; let rows_exported = AtomicU64::new(0); diff --git a/crates/dbx-core/tests/live_postgres_query_result_export.rs b/crates/dbx-core/tests/live_postgres_query_result_export.rs index 4312a693ba..85cb87f518 100644 --- a/crates/dbx-core/tests/live_postgres_query_result_export.rs +++ b/crates/dbx-core/tests/live_postgres_query_result_export.rs @@ -144,6 +144,7 @@ async fn live_postgres_query_result_export_uses_single_streamed_query() { export_table_name: None, export_column_types: None, column_comments: None, + identifier_quote: None, numeric_column_right_align: false, }; let done_seen = AtomicBool::new(false); @@ -224,6 +225,7 @@ async fn live_postgres_query_result_xlsx_preserves_temporal_cell_types() { export_table_name: None, export_column_types: None, column_comments: None, + identifier_quote: None, numeric_column_right_align: false, }; @@ -293,6 +295,7 @@ async fn live_postgres_numeric_xlsx_ignores_fractional_trailing_zeros() { export_table_name: None, export_column_types: None, column_comments: None, + identifier_quote: None, numeric_column_right_align: true, }; @@ -367,6 +370,7 @@ async fn live_postgres_truncated_batch_result_export_replays_safe_temp_setup() { export_table_name: None, export_column_types: None, column_comments: None, + identifier_quote: None, numeric_column_right_align: false, }; let csv_rows = AtomicU64::new(0); @@ -443,6 +447,7 @@ async fn live_postgres_xlsx_export_can_outlive_query_timeout_while_rows_keep_arr export_table_name: None, export_column_types: None, column_comments: None, + identifier_quote: None, numeric_column_right_align: false, }; let rows_exported = AtomicU64::new(0); @@ -510,6 +515,7 @@ async fn live_postgres_stream_still_times_out_without_progress_and_recovers() { export_table_name: None, export_column_types: None, column_comments: None, + identifier_quote: None, numeric_column_right_align: false, }; let started_at = Instant::now(); diff --git a/crates/dbx-core/tests/live_postgres_tsvector.rs b/crates/dbx-core/tests/live_postgres_tsvector.rs index 600a99dd80..379e73469c 100644 --- a/crates/dbx-core/tests/live_postgres_tsvector.rs +++ b/crates/dbx-core/tests/live_postgres_tsvector.rs @@ -70,6 +70,7 @@ async fn postgres_tsvector_generated_columns_are_readable_and_omitted_from_inser }; let copy_insert = build_data_grid_copy_insert_statement(DataGridCopyInsertStatementOptions { database_type: Some(DatabaseType::Postgres), + identifier_quote: None, table_meta: Some(table_meta), columns: result.columns.clone(), column_types: None, @@ -85,6 +86,7 @@ async fn postgres_tsvector_generated_columns_are_readable_and_omitted_from_inser let export_insert = build_export_insert_statements(BuildExportInsertStatementsOptions { database_type: Some(DatabaseType::Postgres), + identifier_quote: None, schema: Some(schema.clone()), table_name: Some("articles".to_string()), qualified_table_name: None, diff --git a/crates/dbx-core/tests/live_sqlserver_completion.rs b/crates/dbx-core/tests/live_sqlserver_completion.rs index 8a755fac9d..86725a7f0a 100644 --- a/crates/dbx-core/tests/live_sqlserver_completion.rs +++ b/crates/dbx-core/tests/live_sqlserver_completion.rs @@ -1201,6 +1201,7 @@ async fn live_sqlserver_query_result_export_streams_cte_query_to_csv() { export_table_name: None, export_column_types: None, column_comments: None, + identifier_quote: None, numeric_column_right_align: false, }; let done_seen = AtomicBool::new(false); diff --git a/crates/dbx-core/tests/live_sqlserver_query_result_export.rs b/crates/dbx-core/tests/live_sqlserver_query_result_export.rs index d7d078f38f..a7d90903fd 100644 --- a/crates/dbx-core/tests/live_sqlserver_query_result_export.rs +++ b/crates/dbx-core/tests/live_sqlserver_query_result_export.rs @@ -119,6 +119,7 @@ async fn live_sqlserver_xlsx_export_can_outlive_query_timeout_while_rows_keep_ar export_table_name: None, export_column_types: None, column_comments: None, + identifier_quote: None, numeric_column_right_align: false, }; let rows_exported = AtomicU64::new(0);