use crate::engine::Engine;
use crate::error::{ErrorCode, McpError};
use crate::stats::{ExportStats, StatsTimer};
use hyperdb_api::{escape_sql_path, escape_string_literal};
use serde_json::{Map, Value};
#[derive(Debug, Default)]
pub struct ExportOptions {
pub sql: Option<String>,
pub table: Option<String>,
pub path: String,
pub format: String,
pub overwrite: bool,
pub format_options: Option<Map<String, Value>>,
}
#[derive(Debug)]
pub struct ExportResult {
pub rows: u64,
pub stats: ExportStats,
}
pub fn export_to_file(engine: &Engine, opts: &ExportOptions) -> Result<ExportResult, McpError> {
let timer = StatsTimer::start();
let path_obj = std::path::Path::new(&opts.path);
if path_obj
.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
{
return Err(McpError::new(
ErrorCode::InvalidArgument,
format!(
"Export path '{}' may not contain '..' components",
opts.path
),
));
}
if !opts.overwrite && std::path::Path::new(&opts.path).exists() {
return Err(McpError::new(
ErrorCode::PermissionDenied,
format!(
"Refusing to overwrite existing destination: {} (pass overwrite=true to replace it)",
opts.path
),
));
}
if opts.format == "hyper" {
return export_hyper(engine, &opts.path, &timer);
}
let select_sql = match (&opts.sql, &opts.table) {
(Some(sql), _) => sql.clone(),
(None, Some(table)) => {
format!("SELECT * FROM \"{}\"", table.replace('"', "\"\""))
}
(None, None) => {
return Err(McpError::new(
ErrorCode::SqlError,
"Either sql or table must be provided",
))
}
};
let extra = opts.format_options.as_ref();
match opts.format.as_str() {
"csv" => export_csv(engine, &select_sql, &opts.path, extra, &timer),
"parquet" => export_parquet(engine, &select_sql, &opts.path, extra, &timer),
"arrow_ipc" => export_arrow_ipc(engine, &select_sql, &opts.path, extra, &timer),
"iceberg" => export_iceberg(
engine,
&select_sql,
&opts.path,
opts.overwrite,
extra,
&timer,
),
other => Err(McpError::new(
ErrorCode::UnsupportedFormat,
format!("Unsupported export format: {other}"),
)),
}
}
fn validate_option_key(key: &str) -> Result<(), McpError> {
let bad = key.is_empty()
|| !key
.bytes()
.next()
.is_some_and(|b| b.is_ascii_alphabetic() || b == b'_')
|| !key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_');
if bad {
return Err(McpError::new(
ErrorCode::SchemaMismatch,
format!(
"format_options key '{key}' must match [A-Za-z_][A-Za-z0-9_]* \
(hyperd COPY option names use that shape)"
),
));
}
Ok(())
}
fn render_option_value(key: &str, value: &Value) -> Result<String, McpError> {
match value {
Value::String(s) => Ok(escape_string_literal(s)),
Value::Bool(b) => Ok(if *b { "true".into() } else { "false".into() }),
Value::Number(n) => Ok(n.to_string()),
Value::Null | Value::Array(_) | Value::Object(_) => Err(McpError::new(
ErrorCode::SchemaMismatch,
format!(
"format_options['{key}'] must be a string, boolean, or number \
(got {value:?})"
),
)),
}
}
fn render_copy_with_clause(
base: &str,
extra: Option<&Map<String, Value>>,
) -> Result<String, McpError> {
let mut clause = base.to_string();
if let Some(opts) = extra {
for (key, value) in opts {
validate_option_key(key)?;
let rendered_value = render_option_value(key, value)?;
clause.push_str(", ");
clause.push_str(key);
clause.push_str(" => ");
clause.push_str(&rendered_value);
}
}
Ok(clause)
}
fn run_copy_to(
engine: &Engine,
sql: &str,
path: &str,
base_with: &str,
extra_options: Option<&Map<String, Value>>,
format_label: &str,
timer: &StatsTimer,
) -> Result<ExportResult, McpError> {
let with_clause = render_copy_with_clause(base_with, extra_options)?;
let quoted_path = escape_string_literal(path);
let copy_sql = format!("COPY ({sql}) TO {quoted_path} WITH ({with_clause})");
let row_count = engine.execute_command(©_sql)?;
let file_size = std::fs::metadata(path).map_or(0, |m| m.len());
Ok(ExportResult {
rows: row_count,
stats: ExportStats {
operation: "export".into(),
rows: row_count,
elapsed_ms: timer.elapsed_ms(),
file_size_bytes: file_size,
format: format_label.into(),
output_path: path.into(),
},
})
}
fn export_csv(
engine: &Engine,
sql: &str,
path: &str,
format_options: Option<&Map<String, Value>>,
timer: &StatsTimer,
) -> Result<ExportResult, McpError> {
run_copy_to(
engine,
sql,
path,
"format => 'csv', header => true",
format_options,
"csv",
timer,
)
}
fn export_parquet(
engine: &Engine,
sql: &str,
path: &str,
format_options: Option<&Map<String, Value>>,
timer: &StatsTimer,
) -> Result<ExportResult, McpError> {
run_copy_to(
engine,
sql,
path,
"format => 'parquet'",
format_options,
"parquet",
timer,
)
}
fn export_arrow_ipc(
engine: &Engine,
sql: &str,
path: &str,
format_options: Option<&Map<String, Value>>,
timer: &StatsTimer,
) -> Result<ExportResult, McpError> {
run_copy_to(
engine,
sql,
path,
"format => 'arrowstream'",
format_options,
"arrow_ipc",
timer,
)
}
fn export_iceberg(
engine: &Engine,
sql: &str,
path: &str,
overwrite: bool,
format_options: Option<&Map<String, Value>>,
timer: &StatsTimer,
) -> Result<ExportResult, McpError> {
let dest = std::path::Path::new(path);
if dest.exists() && overwrite {
if dest.is_dir() {
std::fs::remove_dir_all(dest).map_err(|e| {
McpError::new(
ErrorCode::PermissionDenied,
format!("Cannot remove existing Iceberg directory '{path}': {e}"),
)
})?;
} else {
std::fs::remove_file(dest).map_err(|e| {
McpError::new(
ErrorCode::PermissionDenied,
format!("Cannot remove existing file at '{path}': {e}"),
)
})?;
}
}
let with_clause = render_copy_with_clause("format => 'iceberg'", format_options)?;
let quoted_path = escape_string_literal(path);
let copy_sql = format!("COPY ({sql}) TO {quoted_path} WITH ({with_clause})");
let row_count = engine.execute_command(©_sql)?;
let file_size = walk_dir_size(dest).unwrap_or(0);
Ok(ExportResult {
rows: row_count,
stats: ExportStats {
operation: "export".into(),
rows: row_count,
elapsed_ms: timer.elapsed_ms(),
file_size_bytes: file_size,
format: "iceberg".into(),
output_path: path.into(),
},
})
}
fn walk_dir_size(dir: &std::path::Path) -> std::io::Result<u64> {
let mut total: u64 = 0;
let mut stack = vec![dir.to_path_buf()];
while let Some(p) = stack.pop() {
for entry in std::fs::read_dir(&p)? {
let entry = entry?;
let ft = entry.file_type()?;
if ft.is_dir() {
stack.push(entry.path());
} else if ft.is_file() {
total = total.saturating_add(entry.metadata().map_or(0, |m| m.len()));
}
}
}
Ok(total)
}
fn export_hyper(engine: &Engine, path: &str, timer: &StatsTimer) -> Result<ExportResult, McpError> {
if std::path::Path::new(path).exists() {
std::fs::remove_file(path).map_err(|e| {
McpError::new(
ErrorCode::PermissionDenied,
format!("Cannot remove existing target '{path}': {e}"),
)
})?;
}
let alias = format!(
"__export_target_{}_{}",
std::process::id(),
timer.elapsed_ms(),
);
engine.execute_command(&format!("CREATE DATABASE {}", escape_sql_path(path)))?;
engine.execute_command(&format!(
"ATTACH DATABASE {} AS \"{}\"",
escape_sql_path(path),
alias.replace('"', "\"\""),
))?;
let result = populate_export_target(engine, &alias);
if let Err(e) = engine.execute_command(&format!(
"DETACH DATABASE \"{}\"",
alias.replace('"', "\"\""),
)) {
tracing::warn!(
alias = %alias,
err = %e.message,
"failed to detach export target after export_hyper",
);
}
let rows = result?;
let file_size = std::fs::metadata(path).map_or(0, |m| m.len());
Ok(ExportResult {
rows,
stats: ExportStats {
operation: "export".into(),
rows,
elapsed_ms: timer.elapsed_ms(),
file_size_bytes: file_size,
format: "hyper".into(),
output_path: path.into(),
},
})
}
fn populate_export_target(engine: &Engine, alias: &str) -> Result<u64, McpError> {
let escaped_alias = alias.replace('"', "\"\"");
let primary = engine.primary_db_name();
let escaped_primary = primary.replace('"', "\"\"");
let schemas = list_user_schemas(engine, &escaped_primary)?;
let mut total_rows: u64 = 0;
for schema in &schemas {
let escaped_schema = schema.replace('"', "\"\"");
if schema != "public" {
engine.execute_command(&format!(
"CREATE SCHEMA IF NOT EXISTS \"{escaped_alias}\".\"{escaped_schema}\"",
))?;
}
let tables = list_user_tables(engine, &escaped_primary, schema)?;
for table in &tables {
if crate::engine::is_internal_table(table) {
continue;
}
let escaped_table = table.replace('"', "\"\"");
let rows_copied = engine.execute_command(&format!(
"CREATE TABLE \"{escaped_alias}\".\"{escaped_schema}\".\"{escaped_table}\" AS \
SELECT * FROM \"{escaped_primary}\".\"{escaped_schema}\".\"{escaped_table}\"",
))?;
total_rows = total_rows.saturating_add(rows_copied);
}
}
Ok(total_rows)
}
fn list_user_schemas(engine: &Engine, escaped_db: &str) -> Result<Vec<String>, McpError> {
let sql = format!(
"SELECT nspname FROM \"{escaped_db}\".pg_catalog.pg_namespace \
WHERE nspname NOT IN ('pg_catalog', 'pg_temp', 'information_schema') \
AND nspname NOT LIKE 'pg_%'",
);
let rows = engine.execute_query_to_json(&sql)?;
Ok(rows
.iter()
.filter_map(|r| {
r.get("nspname")
.and_then(|v| v.as_str())
.map(str::to_string)
})
.collect())
}
fn list_user_tables(
engine: &Engine,
escaped_db: &str,
schema: &str,
) -> Result<Vec<String>, McpError> {
let sql = format!(
"SELECT tablename FROM \"{escaped_db}\".pg_catalog.pg_tables WHERE schemaname = {}",
escape_string_literal(schema),
);
let rows = engine.execute_query_to_json(&sql)?;
Ok(rows
.iter()
.filter_map(|r| {
r.get("tablename")
.and_then(|v| v.as_str())
.map(str::to_string)
})
.collect())
}
#[cfg(test)]
mod tests {
use super::{render_copy_with_clause, validate_option_key};
use serde_json::{json, Map, Value};
#[test]
fn render_clause_without_extras_returns_base() {
let out = render_copy_with_clause("format => 'parquet'", None).unwrap();
assert_eq!(out, "format => 'parquet'");
}
#[test]
fn render_clause_appends_extras_after_base() {
let mut m = Map::new();
m.insert("compression".into(), Value::String("zstd".into()));
m.insert("rows_per_row_group".into(), json!(100_000));
let out = render_copy_with_clause("format => 'parquet'", Some(&m)).unwrap();
assert!(out.starts_with("format => 'parquet', "));
assert!(out.contains("compression => 'zstd'"));
assert!(out.contains("rows_per_row_group => 100000"));
}
#[test]
fn render_clause_escapes_string_values() {
let mut m = Map::new();
m.insert("delimiter".into(), Value::String("it's".into()));
let out = render_copy_with_clause("format => 'csv'", Some(&m)).unwrap();
assert!(
out.contains("delimiter => 'it''s'"),
"single quote must be doubled; got: {out}"
);
}
#[test]
fn render_clause_renders_booleans_and_numbers_raw() {
let mut m = Map::new();
m.insert("header".into(), Value::Bool(false));
m.insert("max_file_size".into(), json!(1048576));
m.insert("ratio".into(), json!(0.25));
let out = render_copy_with_clause("format => 'csv'", Some(&m)).unwrap();
assert!(out.contains("header => false"));
assert!(out.contains("max_file_size => 1048576"));
assert!(out.contains("ratio => 0.25"));
}
#[test]
fn render_clause_rejects_null_array_object_values() {
for value in [Value::Null, json!([1, 2]), json!({"nested": 1})] {
let mut m = Map::new();
m.insert("whatever".into(), value.clone());
let err = render_copy_with_clause("format => 'csv'", Some(&m))
.expect_err("non-scalar values must be rejected");
assert!(err.message.contains("whatever"));
}
}
#[test]
fn validate_option_key_accepts_reasonable_names() {
for k in [
"compression",
"rows_per_row_group",
"header",
"h",
"_leading_underscore",
"table_scheme",
"MixedCase", ] {
validate_option_key(k).unwrap_or_else(|e| panic!("{k} should be valid: {e:?}"));
}
}
#[test]
fn validate_option_key_rejects_injection_attempts() {
for bad in [
"",
"1starts_with_digit",
"has-dash",
"has space",
"key;DROP",
"close)--",
"quote'",
"unicode\u{00E9}",
] {
assert!(
validate_option_key(bad).is_err(),
"{bad:?} should be rejected"
);
}
}
}