* Preserve ordered SSH and proxy connection layers Replace separate SSH/proxy connection fields with ordered transport_layers while keeping legacy migration and secret fallback paths intact. The UI now edits SSH tunnel/proxy layers in configured order without a global SSH enable gate. Constraint: Existing saved SSH tunnels, proxy settings, and secret-store keys must continue to load through migration. Rejected: Folding proxy fields into SshTunnelConfig | mixes proxy semantics into an SSH-specific structure. Confidence: high Scope-risk: moderate Directive: Keep SSH/proxy structs provider-specific; put cross-layer chaining in transport_layer_tunnel orchestration. Tested: git diff --check; cargo fmt --check; cargo check --workspace; cargo test -p dbx-core --lib; vue-tsc --noEmit --project apps/desktop/tsconfig.json; tsc -p packages/node-core/tsconfig.json --noEmit; oxlint --vue-plugin apps/desktop/src; tsx --tsconfig apps/desktop/tsconfig.json --test packages/app-tests/*.test.ts; tsx --test packages/node-core/tests/*.test.ts Not-tested: Live external SSH/proxy/database endpoint integration. * Keep ordered transport changes CI-format clean Constraint: CI pnpm check failed only on oxfmt formatting for two desktop TypeScript files Confidence: high Scope-risk: narrow Directive: Keep generated/editor config changes out of this PR fix commit Tested: PATH="/Volumes/data/code/rust/dbx/node_modules/.bin:/Users/hekx/.codex/tmp/arg0/codex-arg0nuL34n:/Users/hekx/.cargo/bin:/Users/hekx/.local/bin:/opt/homebrew/opt/llvm/bin:/opt/homebrew/opt/libpq/bin:/Volumes/data/Users/hekx/.opencode/bin:/Users/hekx/.bun/bin:/Volumes/data/sdks/flutter/bin:/Volumes/data/Users/hekx/.cargo/bin:/Users/hekx/.local/bin:/opt/homebrew/opt/llvm/bin:/opt/homebrew/opt/libpq/bin:/Volumes/data/Users/hekx/.opencode/bin:/Users/hekx/.bun/bin:/Volumes/data/Users/hekx/Library/pnpm:/Volumes/data/sdks/flutter/bin:/Volumes/data/Users/hekx/.cargo/bin:/usr/local/bin:/System/Cryptexes/App/usr/bin:/usr/bin:/bin:/usr/sbin:/sbin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/local/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/appleinternal/bin:/pkg/env/global/bin:/opt/X11/bin:/Library/Apple/usr/bin:/Applications/Wireshark.app/Contents/MacOS:/usr/local/go/bin:/opt/homebrew/bin:/opt/podman/bin:/Applications/Ghostty.app/Contents/MacOS" rtk node scripts/run-check.mjs Not-tested: GitHub Actions rerun not observed locally * Keep Rust checks warning-clean Constraint: cargo clippy --workspace --all-targets --all-features -- -D warnings failed across core, web, and tauri crates Rejected: Broad workspace-level clippy suppression | kept allows local to long-argument command/API boundaries Confidence: high Scope-risk: moderate Directive: Preserve src-tauri/tauri.conf.json as an unrelated local change outside this commit Tested: rtk cargo clippy --workspace --all-targets --all-features -- -D warnings Not-tested: Full GitHub Actions rerun not observed locally * Reuse existing proxy tunnels on retry Proxy tunnel startup now mirrors SSH tunnel behavior by returning the existing local port for an active connection id instead of replacing the managed handle. A second map check aborts a just-spawned duplicate handle if a concurrent retry won the race before insertion, preventing orphaned listeners while keeping the change narrow. Constraint: Reviewer requested proxy tunnel behavior align with SSH local-port reuse Rejected: Always overwrite and abort the previous handle | less consistent with SSH behavior and churns listeners during retries Confidence: high Scope-risk: narrow Tested: cargo fmt --check --all Tested: cargo test -p dbx-core db::proxy_tunnel::tests::start_tunnel_reuses_existing_local_port Tested: cargo clippy -p dbx-core --all-targets -- -D warnings --------- Co-authored-by: hekx <hekx@momandeMac-mini.local> Co-authored-by: caisin <caisin@caisins-Mac-mini.local>
163 lines
5.2 KiB
Rust
163 lines
5.2 KiB
Rust
use serde::{Deserialize, Serialize};
|
|
use serde_json::Value;
|
|
use std::fs::File;
|
|
use std::io::{BufWriter, Write};
|
|
|
|
use crate::connection::AppState;
|
|
use crate::models::connection::DatabaseType;
|
|
use crate::query::{execute_sql_statement_with_options, QueryExecutionOptions};
|
|
use crate::sql_dialect::{build_table_data_select_sql, TableDataSelectSqlOptions};
|
|
|
|
const TABLE_DATA_EXPORT_PAGE_SIZE: usize = 10_000;
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct TableCsvExportOptions {
|
|
pub file_path: String,
|
|
pub connection_id: String,
|
|
pub database: String,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub schema: Option<String>,
|
|
pub table_name: String,
|
|
#[serde(default)]
|
|
pub columns: Vec<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub page_size: Option<usize>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub timeout_secs: Option<u64>,
|
|
}
|
|
|
|
pub(crate) fn escape_csv(value: &str) -> String {
|
|
format!("\"{}\"", value.replace('"', "\"\""))
|
|
}
|
|
|
|
pub(crate) fn value_to_csv_text(value: &Value) -> String {
|
|
match value {
|
|
Value::Null => String::new(),
|
|
Value::Bool(v) => v.to_string(),
|
|
Value::Number(v) => v.to_string(),
|
|
Value::String(v) => v.clone(),
|
|
other => other.to_string(),
|
|
}
|
|
}
|
|
|
|
pub fn format_csv(columns: &[String], rows: &[Vec<Value>]) -> String {
|
|
let header = columns.iter().map(|col| escape_csv(col)).collect::<Vec<_>>().join(",");
|
|
let body = rows
|
|
.iter()
|
|
.map(|row| row.iter().map(|cell| escape_csv(&value_to_csv_text(cell))).collect::<Vec<_>>().join(","))
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
format!("{header}\n{body}")
|
|
}
|
|
|
|
fn write_csv_row(writer: &mut impl Write, values: impl IntoIterator<Item = String>) -> Result<(), String> {
|
|
let mut first = true;
|
|
for value in values {
|
|
if !first {
|
|
writer.write_all(b",").map_err(|err| err.to_string())?;
|
|
}
|
|
first = false;
|
|
writer.write_all(escape_csv(&value).as_bytes()).map_err(|err| err.to_string())?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn connection_database_type(state: &AppState, connection_id: &str) -> Result<DatabaseType, String> {
|
|
state
|
|
.configs
|
|
.read()
|
|
.await
|
|
.get(connection_id)
|
|
.map(|config| config.db_type)
|
|
.ok_or_else(|| format!("Connection config not found: {connection_id}"))
|
|
}
|
|
|
|
pub async fn export_table_data_csv_core(state: &AppState, options: TableCsvExportOptions) -> Result<u64, String> {
|
|
let database_type = connection_database_type(state, &options.connection_id).await?;
|
|
let page_size = options.page_size.unwrap_or(TABLE_DATA_EXPORT_PAGE_SIZE).max(1);
|
|
let mut writer =
|
|
BufWriter::new(File::create(&options.file_path).map_err(|err| format!("Failed to write CSV file: {err}"))?);
|
|
writer.write_all("\u{FEFF}".as_bytes()).map_err(|err| err.to_string())?;
|
|
|
|
let mut offset = 0usize;
|
|
let mut rows_exported = 0u64;
|
|
let mut wrote_header = false;
|
|
|
|
loop {
|
|
let sql = build_table_data_select_sql(TableDataSelectSqlOptions {
|
|
database_type: Some(database_type),
|
|
schema: options.schema.clone(),
|
|
table_name: options.table_name.clone(),
|
|
primary_keys: Vec::new(),
|
|
columns: options.columns.clone(),
|
|
fallback_order_columns: Vec::new(),
|
|
order_by: None,
|
|
limit: Some(page_size),
|
|
offset: Some(offset),
|
|
where_input: None,
|
|
include_row_id: false,
|
|
});
|
|
let result = execute_sql_statement_with_options(
|
|
state,
|
|
&options.connection_id,
|
|
&options.database,
|
|
&sql,
|
|
options.schema.as_deref(),
|
|
None,
|
|
QueryExecutionOptions {
|
|
max_rows: Some(page_size),
|
|
timeout_secs: options.timeout_secs,
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await?;
|
|
|
|
if !wrote_header {
|
|
write_csv_row(&mut writer, result.columns)?;
|
|
wrote_header = true;
|
|
}
|
|
|
|
let fetched = result.rows.len();
|
|
if fetched == 0 {
|
|
break;
|
|
}
|
|
for row in result.rows {
|
|
writer.write_all(b"\n").map_err(|err| err.to_string())?;
|
|
write_csv_row(&mut writer, row.iter().map(value_to_csv_text))?;
|
|
}
|
|
|
|
rows_exported += fetched as u64;
|
|
if fetched < page_size {
|
|
break;
|
|
}
|
|
offset += fetched;
|
|
}
|
|
|
|
if rows_exported == 0 {
|
|
writer.write_all(b"\n").map_err(|err| err.to_string())?;
|
|
}
|
|
writer.flush().map_err(|err| err.to_string())?;
|
|
Ok(rows_exported)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::format_csv;
|
|
use serde_json::json;
|
|
|
|
#[test]
|
|
fn formats_csv_with_header_and_escaped_values() {
|
|
let out = format_csv(&["id".to_string(), "name".to_string()], &[vec![json!(1), json!("Ada \"Lovelace\"")]]);
|
|
assert_eq!(out, "\"id\",\"name\"\n\"1\",\"Ada \"\"Lovelace\"\"\"");
|
|
}
|
|
|
|
#[test]
|
|
fn formats_null_as_empty_cell() {
|
|
let out = format_csv(&["id".to_string(), "note".to_string()], &[vec![json!(1), Value::Null]]);
|
|
assert_eq!(out, "\"id\",\"note\"\n\"1\",\"\"");
|
|
}
|
|
|
|
use serde_json::Value;
|
|
}
|