mod common;
use common::TestEngine;
use hyperdb_mcp::engine::Engine;
use hyperdb_mcp::error::ErrorCode;
use hyperdb_mcp::export::{export_to_file, ExportOptions};
fn setup_test_table(te: &TestEngine) {
te.engine
.execute_command(
"CREATE TABLE test_export (id INT NOT NULL, name TEXT, val DOUBLE PRECISION)",
)
.unwrap();
te.engine
.execute_command("INSERT INTO test_export VALUES (1, 'Alice', 10.5)")
.unwrap();
te.engine
.execute_command("INSERT INTO test_export VALUES (2, 'Bob', 20.3)")
.unwrap();
}
#[test]
fn export_csv_from_table() {
let te = TestEngine::new_ephemeral();
setup_test_table(&te);
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("export.csv");
let path_str = path.to_str().unwrap();
let opts = ExportOptions {
sql: None,
table: Some("test_export".into()),
path: path_str.into(),
format: "csv".into(),
overwrite: true,
format_options: None,
source_db: None,
};
let result = export_to_file(&te.engine, &opts).unwrap();
assert_eq!(result.rows, 2);
let contents = std::fs::read_to_string(path_str).unwrap();
assert!(contents.contains("Alice"));
assert!(contents.contains("Bob"));
}
#[test]
fn export_csv_from_query() {
let te = TestEngine::new_ephemeral();
setup_test_table(&te);
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("query_export.csv");
let path_str = path.to_str().unwrap();
let opts = ExportOptions {
sql: Some("SELECT name FROM test_export WHERE id = 1".into()),
table: None,
path: path_str.into(),
format: "csv".into(),
overwrite: true,
format_options: None,
source_db: None,
};
let result = export_to_file(&te.engine, &opts).unwrap();
assert_eq!(result.rows, 1);
}
#[test]
fn export_parquet_from_table() {
let te = TestEngine::new_ephemeral();
setup_test_table(&te);
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("export.parquet");
let path_str = path.to_str().unwrap();
let opts = ExportOptions {
sql: None,
table: Some("test_export".into()),
path: path_str.into(),
format: "parquet".into(),
overwrite: true,
format_options: None,
source_db: None,
};
let result = export_to_file(&te.engine, &opts).unwrap();
assert_eq!(result.rows, 2);
assert!(std::fs::metadata(path_str).unwrap().len() > 0);
}
#[test]
fn export_hyper_copies_workspace() {
let te = TestEngine::new_ephemeral();
setup_test_table(&te);
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("export.hyper");
let path_str = path.to_str().unwrap();
let opts = ExportOptions {
sql: None,
table: Some("test_export".into()),
path: path_str.into(),
format: "hyper".into(),
overwrite: true,
format_options: None,
source_db: None,
};
let _result = export_to_file(&te.engine, &opts).unwrap();
assert!(std::fs::metadata(path_str).unwrap().len() > 0);
}
#[test]
fn export_hyper_with_source_db_snapshots_persistent() {
let te = TestEngine::new_ephemeral();
te.engine
.execute_command("CREATE TABLE primary_only (n INT)")
.unwrap();
te.engine
.execute_command("INSERT INTO primary_only VALUES (99)")
.unwrap();
te.engine
.execute_command(
"CREATE TABLE \"persistent\".\"public\".\"persisted\" (id INT, label TEXT)",
)
.unwrap();
te.engine
.execute_command("INSERT INTO \"persistent\".\"public\".\"persisted\" VALUES (1, 'a')")
.unwrap();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("snapshot.hyper");
let path_str = path.to_str().unwrap();
let opts = ExportOptions {
sql: None,
table: None,
path: path_str.into(),
format: "hyper".into(),
overwrite: true,
format_options: None,
source_db: Some("persistent".into()),
};
export_to_file(&te.engine, &opts).expect("hyper export with source_db must succeed");
let probe_engine = Engine::new_no_daemon(Some(path_str.into())).unwrap();
let rows = probe_engine
.execute_query_to_json(
"SELECT tablename FROM \"persistent\".pg_catalog.pg_tables \
WHERE schemaname = 'public' ORDER BY tablename",
)
.unwrap();
let names: Vec<String> = rows
.iter()
.filter_map(|r| {
r.get("tablename")
.and_then(|v| v.as_str())
.map(String::from)
})
.collect();
assert!(
names.contains(&"persisted".into()),
"snapshot must contain 'persisted' from source_db; got {names:?}"
);
assert!(
!names.contains(&"primary_only".into()),
"snapshot must NOT contain 'primary_only' (lives in primary, not persistent); got {names:?}"
);
}
#[test]
fn export_hyper_requires_no_sql_or_table() {
let te = TestEngine::new_ephemeral();
setup_test_table(&te);
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("bare_export.hyper");
let path_str = path.to_str().unwrap();
let opts = ExportOptions {
sql: None,
table: None,
path: path_str.into(),
format: "hyper".into(),
overwrite: true,
format_options: None,
source_db: None,
};
let result = export_to_file(&te.engine, &opts)
.expect("hyper format must accept a bare (path, format) call without sql or table");
assert_eq!(result.rows, 0);
assert_eq!(result.stats.format, "hyper");
assert_eq!(result.stats.output_path, path_str);
assert!(std::fs::metadata(path_str).unwrap().len() > 0);
}
#[test]
fn export_csv_without_sql_or_table_errors() {
let te = TestEngine::new_ephemeral();
setup_test_table(&te);
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("bad.csv");
let path_str = path.to_str().unwrap();
let opts = ExportOptions {
sql: None,
table: None,
path: path_str.into(),
format: "csv".into(),
overwrite: true,
format_options: None,
source_db: None,
};
let Err(err) = export_to_file(&te.engine, &opts) else {
panic!("csv export must reject calls with no sql and no table")
};
assert!(
err.message.contains("sql") && err.message.contains("table"),
"expected sql/table error, got: {}",
err.message
);
}
#[test]
fn export_overwrite_false_rejects_existing_file() {
let te = TestEngine::new_ephemeral();
setup_test_table(&te);
let dir = tempfile::tempdir().unwrap();
let csv_path = dir.path().join("exists.csv");
let sentinel_csv = b"SENTINEL_ORIGINAL_CONTENTS\n";
std::fs::write(&csv_path, sentinel_csv).unwrap();
let Err(err) = export_to_file(
&te.engine,
&ExportOptions {
sql: None,
table: Some("test_export".into()),
path: csv_path.to_str().unwrap().into(),
format: "csv".into(),
overwrite: false,
format_options: None,
source_db: None,
},
) else {
panic!("export with overwrite=false must error when target exists")
};
assert_eq!(err.code, ErrorCode::PermissionDenied);
assert!(
err.message.to_lowercase().contains("overwrite"),
"expected overwrite hint in error message, got: {}",
err.message
);
let after = std::fs::read(&csv_path).unwrap();
assert_eq!(
after, sentinel_csv,
"existing file must be untouched when overwrite=false"
);
let hyper_path = dir.path().join("exists.hyper");
let sentinel_hyper = b"SENTINEL_NOT_A_REAL_HYPER_FILE";
std::fs::write(&hyper_path, sentinel_hyper).unwrap();
let Err(err) = export_to_file(
&te.engine,
&ExportOptions {
sql: None,
table: None,
path: hyper_path.to_str().unwrap().into(),
format: "hyper".into(),
overwrite: false,
format_options: None,
source_db: None,
},
) else {
panic!("hyper export with overwrite=false must error when target exists")
};
assert_eq!(err.code, ErrorCode::PermissionDenied);
let after = std::fs::read(&hyper_path).unwrap();
assert_eq!(
after, sentinel_hyper,
"existing .hyper file must be untouched when overwrite=false"
);
}
#[test]
fn export_overwrite_false_allows_new_path() {
let te = TestEngine::new_ephemeral();
setup_test_table(&te);
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("fresh.csv");
let opts = ExportOptions {
sql: None,
table: Some("test_export".into()),
path: path.to_str().unwrap().into(),
format: "csv".into(),
overwrite: false,
format_options: None,
source_db: None,
};
let result = export_to_file(&te.engine, &opts)
.expect("overwrite=false must succeed when target doesn't exist yet");
assert_eq!(result.rows, 2);
assert!(std::fs::metadata(&path).unwrap().len() > 0);
}
#[test]
fn export_overwrite_true_replaces_existing_file() {
let te = TestEngine::new_ephemeral();
setup_test_table(&te);
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("replace.csv");
std::fs::write(&path, b"SENTINEL_OLD").unwrap();
let opts = ExportOptions {
sql: None,
table: Some("test_export".into()),
path: path.to_str().unwrap().into(),
format: "csv".into(),
overwrite: true,
format_options: None,
source_db: None,
};
let result = export_to_file(&te.engine, &opts)
.expect("overwrite=true must succeed even when target already exists");
assert_eq!(result.rows, 2);
let contents = std::fs::read_to_string(&path).unwrap();
assert!(
!contents.contains("SENTINEL_OLD"),
"sentinel not replaced — file contents were: {contents}"
);
assert!(contents.contains("Alice") && contents.contains("Bob"));
}
#[test]
fn iceberg_export_round_trips_through_load_iceberg() {
use hyperdb_mcp::lakehouse::{ingest_iceberg_table, IcebergIngestOptions};
let te = TestEngine::new_ephemeral();
setup_test_table(&te);
let dir = tempfile::tempdir().unwrap();
let iceberg_path = dir.path().join("export_iceberg");
let iceberg_str = iceberg_path.to_str().unwrap();
let export_opts = ExportOptions {
sql: None,
table: Some("test_export".into()),
path: iceberg_str.into(),
format: "iceberg".into(),
overwrite: true,
format_options: None,
source_db: None,
};
let export_result = export_to_file(&te.engine, &export_opts).unwrap();
assert_eq!(export_result.rows, 2);
assert!(
iceberg_path.is_dir(),
"iceberg export must produce a directory"
);
assert!(
iceberg_path.join("metadata").is_dir(),
"iceberg export must produce a metadata/ subdir"
);
let ingest_opts = IcebergIngestOptions {
table: "test_reloaded".into(),
mode: "replace".into(),
metadata_filename: None,
version_as_of: None,
};
let ingest_result = ingest_iceberg_table(&te.engine, iceberg_str, &ingest_opts).unwrap();
assert_eq!(ingest_result.rows, 2);
assert_eq!(
ingest_result.schema.len(),
3,
"load_iceberg should report all 3 columns from test_export, got: {:?}",
ingest_result.schema
);
let names: Vec<&str> = ingest_result
.schema
.iter()
.map(|c| c.name.as_str())
.collect();
assert!(names.contains(&"id"));
assert!(names.contains(&"name"));
assert!(names.contains(&"val"));
let rows = te
.engine
.execute_query_to_json("SELECT id, name, val FROM test_reloaded ORDER BY id")
.unwrap();
assert_eq!(rows.len(), 2);
assert_eq!(rows[0]["id"], 1);
assert_eq!(rows[0]["name"], "Alice");
assert_eq!(rows[1]["id"], 2);
assert_eq!(rows[1]["name"], "Bob");
let src_minus_reload = te
.engine
.execute_query_to_json(
"SELECT COUNT(*) AS n FROM (SELECT * FROM test_export EXCEPT SELECT * FROM test_reloaded) d",
)
.unwrap();
assert_eq!(src_minus_reload[0]["n"], 0);
let reload_minus_src = te
.engine
.execute_query_to_json(
"SELECT COUNT(*) AS n FROM (SELECT * FROM test_reloaded EXCEPT SELECT * FROM test_export) d",
)
.unwrap();
assert_eq!(reload_minus_src[0]["n"], 0);
}
#[test]
fn iceberg_export_overwrite_replaces_directory() {
let te = TestEngine::new_ephemeral();
setup_test_table(&te);
let dir = tempfile::tempdir().unwrap();
let iceberg_path = dir.path().join("export_iceberg_overwrite");
let iceberg_str = iceberg_path.to_str().unwrap();
let opts_first = ExportOptions {
sql: None,
table: Some("test_export".into()),
path: iceberg_str.into(),
format: "iceberg".into(),
overwrite: true,
format_options: None,
source_db: None,
};
let r1 = export_to_file(&te.engine, &opts_first).unwrap();
assert_eq!(r1.rows, 2);
let opts_second = ExportOptions {
sql: Some("SELECT * FROM test_export WHERE id = 1".into()),
table: None,
path: iceberg_str.into(),
format: "iceberg".into(),
overwrite: true,
format_options: None,
source_db: None,
};
let r2 = export_to_file(&te.engine, &opts_second).unwrap();
assert_eq!(r2.rows, 1);
use hyperdb_mcp::lakehouse::{ingest_iceberg_table, IcebergIngestOptions};
let ingest = ingest_iceberg_table(
&te.engine,
iceberg_str,
&IcebergIngestOptions {
table: "t".into(),
mode: "replace".into(),
metadata_filename: None,
version_as_of: None,
},
)
.unwrap();
assert_eq!(ingest.rows, 1);
}
#[test]
fn iceberg_export_refuses_overwrite_when_disabled() {
let te = TestEngine::new_ephemeral();
setup_test_table(&te);
let dir = tempfile::tempdir().unwrap();
let iceberg_path = dir.path().join("export_iceberg_no_overwrite");
let iceberg_str = iceberg_path.to_str().unwrap();
let opts_first = ExportOptions {
sql: None,
table: Some("test_export".into()),
path: iceberg_str.into(),
format: "iceberg".into(),
overwrite: true,
format_options: None,
source_db: None,
};
export_to_file(&te.engine, &opts_first).unwrap();
let opts_second = ExportOptions {
sql: None,
table: Some("test_export".into()),
path: iceberg_str.into(),
format: "iceberg".into(),
overwrite: false,
format_options: None,
source_db: None,
};
let err = export_to_file(&te.engine, &opts_second).err().unwrap();
assert_eq!(err.code, ErrorCode::PermissionDenied);
}
#[test]
fn parquet_export_round_trips_through_load_file() {
use hyperdb_mcp::ingest::IngestOptions;
use hyperdb_mcp::ingest_arrow::ingest_parquet_file;
let te = TestEngine::new_ephemeral();
te.engine
.execute_command(
"CREATE TABLE pq_export_src ( \
id INT NOT NULL, \
name TEXT NOT NULL, \
amount NUMERIC(15, 2) NOT NULL, \
val DOUBLE PRECISION, \
d DATE NOT NULL \
)",
)
.unwrap();
te.engine
.execute_command(
"INSERT INTO pq_export_src VALUES \
(1, 'Alice', 123.45, 10.5, DATE '2024-01-15'), \
(2, 'Bob', -678.90, 20.3, DATE '2025-07-04'), \
(3, 'Cara', 0.00, NULL, DATE '1999-12-31')",
)
.unwrap();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("out.parquet");
let path_str = path.to_str().unwrap();
let export_result = export_to_file(
&te.engine,
&ExportOptions {
sql: None,
table: Some("pq_export_src".into()),
path: path_str.into(),
format: "parquet".into(),
overwrite: true,
format_options: None,
source_db: None,
},
)
.unwrap();
assert_eq!(export_result.rows, 3);
assert!(std::fs::metadata(path_str).unwrap().len() > 0);
let ingest_result = ingest_parquet_file(
&te.engine,
path_str,
&IngestOptions {
table: "pq_export_reloaded".into(),
mode: "replace".into(),
schema_override: None,
merge_key: None,
target_db: None,
},
)
.unwrap();
assert_eq!(ingest_result.rows, 3);
let col_type = |name: &str| -> String {
ingest_result
.schema
.iter()
.find(|c| c.name == name)
.map(|c| c.hyper_type.clone())
.unwrap_or_default()
};
assert_eq!(col_type("id"), "INT");
assert_eq!(col_type("name"), "TEXT");
assert_eq!(col_type("amount"), "NUMERIC(15, 2)");
assert_eq!(col_type("val"), "DOUBLE PRECISION");
assert_eq!(col_type("d"), "DATE");
let src_minus_reload = te
.engine
.execute_query_to_json(
"SELECT COUNT(*) AS n FROM (SELECT * FROM pq_export_src EXCEPT SELECT * FROM pq_export_reloaded) d",
)
.unwrap();
assert_eq!(src_minus_reload[0]["n"], 0);
let reload_minus_src = te
.engine
.execute_query_to_json(
"SELECT COUNT(*) AS n FROM (SELECT * FROM pq_export_reloaded EXCEPT SELECT * FROM pq_export_src) d",
)
.unwrap();
assert_eq!(reload_minus_src[0]["n"], 0);
let rows = te
.engine
.execute_query_to_json(
"SELECT amount::TEXT AS amount_str, d::TEXT AS d_str FROM pq_export_reloaded ORDER BY id",
)
.unwrap();
assert_eq!(rows[0]["amount_str"], "123.45");
assert_eq!(rows[1]["amount_str"], "-678.90");
assert_eq!(rows[2]["amount_str"], "0.00");
assert_eq!(rows[0]["d_str"], "2024-01-15");
}
#[test]
fn arrow_ipc_export_round_trips_through_load_file() {
use hyperdb_mcp::ingest::IngestOptions;
use hyperdb_mcp::ingest_arrow::ingest_arrow_ipc_file;
let te = TestEngine::new_ephemeral();
setup_test_table(&te);
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("out.arrow");
let path_str = path.to_str().unwrap();
let export_result = export_to_file(
&te.engine,
&ExportOptions {
sql: None,
table: Some("test_export".into()),
path: path_str.into(),
format: "arrow_ipc".into(),
overwrite: true,
format_options: None,
source_db: None,
},
)
.unwrap();
assert_eq!(export_result.rows, 2);
assert!(std::fs::metadata(path_str).unwrap().len() > 0);
let ingest_result = ingest_arrow_ipc_file(
&te.engine,
path_str,
&IngestOptions {
table: "arrow_reloaded".into(),
mode: "replace".into(),
schema_override: None,
merge_key: None,
target_db: None,
},
)
.unwrap();
assert_eq!(ingest_result.rows, 2);
let src_minus_reload = te
.engine
.execute_query_to_json(
"SELECT COUNT(*) AS n FROM (SELECT * FROM test_export EXCEPT SELECT * FROM arrow_reloaded) d",
)
.unwrap();
assert_eq!(src_minus_reload[0]["n"], 0);
let reload_minus_src = te
.engine
.execute_query_to_json(
"SELECT COUNT(*) AS n FROM (SELECT * FROM arrow_reloaded EXCEPT SELECT * FROM test_export) d",
)
.unwrap();
assert_eq!(reload_minus_src[0]["n"], 0);
}
#[test]
fn parquet_export_honors_compression_override() {
let te = TestEngine::new_ephemeral();
te.engine
.execute_command(
"CREATE TABLE compression_src AS \
SELECT i AS id, repeat('abcdefg', 200) AS payload \
FROM generate_series(1, 20000) s(i)",
)
.unwrap();
let dir = tempfile::tempdir().unwrap();
let export_with = |compression: &str, name: &str| -> u64 {
let path = dir.path().join(name);
let mut opts = serde_json::Map::new();
opts.insert(
"codec".into(),
serde_json::Value::String(compression.into()),
);
export_to_file(
&te.engine,
&ExportOptions {
sql: None,
table: Some("compression_src".into()),
path: path.to_str().unwrap().into(),
format: "parquet".into(),
overwrite: true,
format_options: Some(opts),
source_db: None,
},
)
.expect("parquet export with compression override should succeed");
std::fs::metadata(&path).unwrap().len()
};
let uncompressed = export_with("uncompressed", "u.parquet");
let zstd = export_with("zstd", "z.parquet");
assert!(
uncompressed > zstd,
"expected zstd < uncompressed on a repetitive payload; \
got uncompressed={uncompressed} zstd={zstd}"
);
let delta = uncompressed.abs_diff(zstd);
assert!(
delta > 1024,
"expected the codec override to make a meaningful size \
difference (>1 KB); got delta={delta}"
);
}
#[test]
fn csv_export_honors_delimiter_override() {
let te = TestEngine::new_ephemeral();
setup_test_table(&te);
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("tab.tsv");
let path_str = path.to_str().unwrap();
let mut opts = serde_json::Map::new();
opts.insert("delimiter".into(), serde_json::Value::String("\t".into()));
export_to_file(
&te.engine,
&ExportOptions {
sql: None,
table: Some("test_export".into()),
path: path_str.into(),
format: "csv".into(),
overwrite: true,
format_options: Some(opts),
source_db: None,
},
)
.unwrap();
let contents = std::fs::read_to_string(path_str).unwrap();
assert!(
contents.contains('\t'),
"CSV output must contain tabs when delimiter => '\\t'; got: {contents}"
);
let header = contents.lines().next().unwrap();
assert!(
!header.contains(','),
"header must not contain commas with a tab delimiter; got: {header}"
);
}
#[test]
fn format_options_invalid_shapes_reject_cleanly() {
let te = TestEngine::new_ephemeral();
setup_test_table(&te);
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("bad.parquet");
let path_str = path.to_str().unwrap().to_string();
let mut null_val = serde_json::Map::new();
null_val.insert("compression".into(), serde_json::Value::Null);
let err = export_to_file(
&te.engine,
&ExportOptions {
sql: None,
table: Some("test_export".into()),
path: path_str.clone(),
format: "parquet".into(),
overwrite: true,
format_options: Some(null_val),
source_db: None,
},
)
.err()
.unwrap();
assert!(err.message.to_lowercase().contains("compression"));
let mut bad_key = serde_json::Map::new();
bad_key.insert(
"compression); DROP TABLE test_export --".into(),
serde_json::Value::String("zstd".into()),
);
let err = export_to_file(
&te.engine,
&ExportOptions {
sql: None,
table: Some("test_export".into()),
path: path_str.clone(),
format: "parquet".into(),
overwrite: true,
format_options: Some(bad_key),
source_db: None,
},
)
.err()
.unwrap();
assert!(err.message.to_lowercase().contains("format_options key"));
let rows = te
.engine
.execute_query_to_json("SELECT COUNT(*) AS n FROM test_export")
.unwrap();
assert_eq!(rows[0]["n"], 2);
}