mod common;
use common::TestEngine;
use hyperdb_mcp::ingest::{
detect_file_format, extract_json_path, ingest_csv, ingest_csv_file, ingest_json,
ingest_json_file, InferredFileFormat, IngestOptions,
};
use std::io::Write;
use tempfile::TempPath;
#[test]
fn ingest_json_basic() {
let te = TestEngine::new_ephemeral();
let data = r#"[{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}]"#;
let opts = IngestOptions {
table: "users".into(),
mode: "replace".into(),
schema_override: None,
merge_key: None,
target_db: None,
};
let result = ingest_json(&te.engine, data, &opts).unwrap();
assert_eq!(result.rows, 2);
let rows = te
.engine
.execute_query_to_json("SELECT * FROM users ORDER BY id")
.unwrap();
assert_eq!(rows.len(), 2);
assert_eq!(rows[0]["name"], "Alice");
}
#[test]
fn ingest_json_append_mode() {
let te = TestEngine::new_ephemeral();
let data1 = r#"[{"id": 1}]"#;
let data2 = r#"[{"id": 2}]"#;
let opts_replace = IngestOptions {
table: "t".into(),
mode: "replace".into(),
schema_override: None,
merge_key: None,
target_db: None,
};
let opts_append = IngestOptions {
table: "t".into(),
mode: "append".into(),
schema_override: None,
merge_key: None,
target_db: None,
};
ingest_json(&te.engine, data1, &opts_replace).unwrap();
ingest_json(&te.engine, data2, &opts_append).unwrap();
let count: i64 = te
.engine
.connection()
.execute_scalar_query("SELECT COUNT(*) FROM t")
.unwrap()
.unwrap();
assert_eq!(count, 2);
}
#[test]
fn ingest_csv_basic() {
let te = TestEngine::new_ephemeral();
let csv_text = "id,name,score\n1,Alice,95.5\n2,Bob,88.0\n";
let opts = IngestOptions {
table: "scores".into(),
mode: "replace".into(),
schema_override: None,
merge_key: None,
target_db: None,
};
let result = ingest_csv(&te.engine, csv_text, &opts).unwrap();
assert_eq!(result.rows, 2);
let rows = te
.engine
.execute_query_to_json("SELECT * FROM scores ORDER BY id")
.unwrap();
assert_eq!(rows.len(), 2);
}
#[test]
fn ingest_json_with_schema_override() {
let te = TestEngine::new_ephemeral();
let data = r#"[{"amount": "123.45"}]"#;
let mut schema = serde_json::Map::new();
schema.insert(
"amount".into(),
serde_json::Value::String("DOUBLE PRECISION".into()),
);
let opts = IngestOptions {
table: "orders".into(),
mode: "replace".into(),
schema_override: Some(schema),
merge_key: None,
target_db: None,
};
let result = ingest_json(&te.engine, data, &opts).unwrap();
assert_eq!(result.rows, 1);
}
#[test]
fn ingest_json_empty_returns_error() {
let te = TestEngine::new_ephemeral();
let data = "[]";
let opts = IngestOptions {
table: "empty".into(),
mode: "replace".into(),
schema_override: None,
merge_key: None,
target_db: None,
};
let result = ingest_json(&te.engine, data, &opts);
assert!(result.is_err());
}
fn tmp_with_ext(ext: &str, content: &[u8]) -> (String, TempPath) {
let mut file = tempfile::Builder::new()
.suffix(&format!(".{ext}"))
.tempfile()
.expect("create temp file");
file.write_all(content).expect("write temp");
file.flush().ok();
let temp_path = file.into_temp_path();
let path = temp_path.to_str().expect("utf-8 path").to_string();
(path, temp_path)
}
#[test]
fn ingest_json_file_loads_json_array() {
let te = TestEngine::new_ephemeral();
let (path, _keep) = tmp_with_ext(
"json",
b"[{\"id\":1,\"name\":\"Alice\"},{\"id\":2,\"name\":\"Bob\"},{\"id\":3,\"name\":\"Carol\"}]",
);
let opts = IngestOptions {
table: "people".into(),
mode: "replace".into(),
schema_override: None,
merge_key: None,
target_db: None,
};
let result = ingest_json_file(&te.engine, &path, &opts).unwrap();
assert_eq!(result.rows, 3);
assert_eq!(result.stats.file_format.as_deref(), Some("json"));
assert_eq!(result.stats.operation, "load_file");
let rows = te
.engine
.execute_query_to_json("SELECT id, name FROM people ORDER BY id")
.unwrap();
assert_eq!(rows.len(), 3);
assert_eq!(rows[1]["name"], "Bob");
}
#[test]
fn ingest_json_file_loads_jsonl() {
let te = TestEngine::new_ephemeral();
let jsonl = b"{\"k\":\"start\",\"n\":1}\n\
\n\
{\"k\":\"progress\",\"n\":2}\n\
{\"k\":\"done\",\"n\":3}\n";
let (path, _keep) = tmp_with_ext("jsonl", jsonl);
let opts = IngestOptions {
table: "events".into(),
mode: "replace".into(),
schema_override: None,
merge_key: None,
target_db: None,
};
let result = ingest_json_file(&te.engine, &path, &opts).unwrap();
assert_eq!(result.rows, 3, "blank lines are skipped, data rows count 3");
assert_eq!(result.stats.file_format.as_deref(), Some("jsonl"));
let rows = te
.engine
.execute_query_to_json("SELECT k, n FROM events ORDER BY n")
.unwrap();
assert_eq!(rows.len(), 3);
assert_eq!(rows[0]["k"], "start");
assert_eq!(rows[2]["k"], "done");
}
#[test]
fn ingest_json_file_reports_bad_jsonl_line() {
let te = TestEngine::new_ephemeral();
let bad = b"{\"id\":1}\n\
{\"id\":2}\n\
not-json\n";
let (path, _keep) = tmp_with_ext("jsonl", bad);
let opts = IngestOptions {
table: "t".into(),
mode: "replace".into(),
schema_override: None,
merge_key: None,
target_db: None,
};
let Err(err) = ingest_json_file(&te.engine, &path, &opts) else {
panic!("expected malformed JSONL to error")
};
assert_eq!(err.code, hyperdb_mcp::error::ErrorCode::SchemaMismatch);
assert!(
err.message.contains("line 3"),
"error should name the bad line: {}",
err.message
);
}
#[test]
fn ingest_csv_empty_cells_become_null() {
let te = TestEngine::new_ephemeral();
let csv_text = "id,name,age\n1,Alice,30\n2,Bob,\n3,Carol,42\n";
let opts = IngestOptions {
table: "u".into(),
mode: "replace".into(),
schema_override: None,
merge_key: None,
target_db: None,
};
let result = ingest_csv(&te.engine, csv_text, &opts).unwrap();
assert_eq!(result.rows, 3);
let nulls: i64 = te
.engine
.connection()
.execute_scalar_query("SELECT COUNT(*) FROM u WHERE age IS NULL")
.unwrap()
.unwrap();
assert_eq!(nulls, 1, "exactly one row should have a NULL age");
let non_nulls: i64 = te
.engine
.connection()
.execute_scalar_query("SELECT COUNT(*) FROM u WHERE age IS NOT NULL")
.unwrap()
.unwrap();
assert_eq!(non_nulls, 2);
}
#[test]
fn detect_file_format_matches_binary_extensions() {
assert_eq!(
detect_file_format(std::path::Path::new("/tmp/x.parquet")),
InferredFileFormat::Parquet
);
assert_eq!(
detect_file_format(std::path::Path::new("/tmp/x.pq")),
InferredFileFormat::Parquet
);
assert_eq!(
detect_file_format(std::path::Path::new("/tmp/x.arrow")),
InferredFileFormat::ArrowIpc
);
assert_eq!(
detect_file_format(std::path::Path::new("/tmp/x.ipc")),
InferredFileFormat::ArrowIpc
);
assert_eq!(
detect_file_format(std::path::Path::new("/tmp/x.feather")),
InferredFileFormat::ArrowIpc
);
}
#[test]
fn detect_file_format_matches_json_extensions() {
for ext in ["json", "jsonl", "ndjson"] {
let (path, _keep) = tmp_with_ext(ext, b"[{\"a\":1}]");
assert_eq!(
detect_file_format(std::path::Path::new(&path)),
InferredFileFormat::Json,
"`.{ext}` should map to Json without content sniff"
);
}
}
#[test]
fn detect_file_format_sniffs_jsonl_from_unknown_extension() {
let (path, _keep) = tmp_with_ext(
"log",
b"{\"ts\":\"2026-01-01T00:00:00\",\"k\":\"start\"}\n\
{\"ts\":\"2026-01-01T00:00:01\",\"k\":\"done\"}\n",
);
assert_eq!(
detect_file_format(std::path::Path::new(&path)),
InferredFileFormat::Json
);
}
#[test]
fn detect_file_format_sniffs_json_array_from_unknown_extension() {
let (path, _keep) = tmp_with_ext("log", b" \n [{\"k\":\"v\"}]\n");
assert_eq!(
detect_file_format(std::path::Path::new(&path)),
InferredFileFormat::Json,
"leading whitespace before `[` should still sniff as JSON"
);
}
#[test]
fn detect_file_format_falls_back_to_csv() {
let (path, _keep) = tmp_with_ext("txt", b"id,name,age\n1,Alice,30\n");
assert_eq!(
detect_file_format(std::path::Path::new(&path)),
InferredFileFormat::Csv
);
}
#[test]
fn detect_file_format_defaults_to_csv_when_unreadable() {
assert_eq!(
detect_file_format(std::path::Path::new("/tmp/definitely-not-a-real-file.xyz")),
InferredFileFormat::Csv
);
}
#[test]
fn ingest_json_file_handles_log_extension_via_content_sniff() {
let te = TestEngine::new_ephemeral();
let (path, _keep) = tmp_with_ext(
"log",
b"{\"id\":1,\"msg\":\"hello\"}\n\
{\"id\":2,\"msg\":\"world\"}\n",
);
let fmt = detect_file_format(std::path::Path::new(&path));
assert_eq!(fmt, InferredFileFormat::Json);
let opts = IngestOptions {
table: "events".into(),
mode: "replace".into(),
schema_override: None,
merge_key: None,
target_db: None,
};
let result = ingest_json_file(&te.engine, &path, &opts).unwrap();
assert_eq!(result.rows, 2);
assert_eq!(result.stats.file_format.as_deref(), Some("jsonl"));
}
#[test]
fn ingest_csv_file_empty_cells_become_null() {
let te = TestEngine::new_ephemeral();
let (path, _keep) = tmp_with_ext("csv", b"code,label\nAAA,first\n,middle\nBBB,\n");
let opts = IngestOptions {
table: "lookup".into(),
mode: "replace".into(),
schema_override: None,
merge_key: None,
target_db: None,
};
let result = ingest_csv_file(&te.engine, &path, &opts).unwrap();
assert_eq!(result.rows, 3);
let code_nulls: i64 = te
.engine
.connection()
.execute_scalar_query("SELECT COUNT(*) FROM lookup WHERE code IS NULL")
.unwrap()
.unwrap();
let label_nulls: i64 = te
.engine
.connection()
.execute_scalar_query("SELECT COUNT(*) FROM lookup WHERE label IS NULL")
.unwrap()
.unwrap();
assert_eq!(code_nulls, 1, "row 2's empty code should be NULL");
assert_eq!(label_nulls, 1, "row 3's empty label should be NULL");
}
#[test]
fn extract_json_path_simple_object() {
let json = r#"{"results": [{"a": 1}, {"a": 2}]}"#;
let out = extract_json_path(json, "results").unwrap();
let arr: Vec<serde_json::Value> = serde_json::from_str(&out).unwrap();
assert_eq!(arr.len(), 2);
}
#[test]
fn extract_json_path_array_index() {
let json = r#"{"content": [{"type": "text"}, {"type": "image"}]}"#;
let out = extract_json_path(json, "content.0").unwrap();
let obj: serde_json::Value = serde_json::from_str(&out).unwrap();
assert_eq!(obj["type"], "text");
}
#[test]
fn extract_json_path_stringified_json() {
let inner = serde_json::json!({
"status": "success",
"query_result": {
"results": [
{"_time": "2026-01-01T00:00:00Z", "_raw": "log line 1"},
{"_time": "2026-01-01T00:01:00Z", "_raw": "log line 2"},
]
}
});
let wrapper = serde_json::json!({
"content": [{
"type": "text",
"text": inner.to_string()
}]
});
let out =
extract_json_path(&wrapper.to_string(), "content.0.text.query_result.results").unwrap();
let arr: Vec<serde_json::Value> = serde_json::from_str(&out).unwrap();
assert_eq!(arr.len(), 2);
assert_eq!(arr[0]["_raw"], "log line 1");
}
#[test]
fn extract_json_path_terminal_string_parsed() {
let json = r#"{"data": "[{\"a\":1},{\"a\":2}]"}"#;
let out = extract_json_path(json, "data").unwrap();
let arr: Vec<serde_json::Value> = serde_json::from_str(&out).unwrap();
assert_eq!(arr.len(), 2);
assert_eq!(arr[0]["a"], 1);
}
#[test]
fn extract_json_path_missing_key() {
let json = r#"{"other": 1}"#;
let err = extract_json_path(json, "missing").unwrap_err();
assert!(
err.message.contains("key not found"),
"error should mention missing key: {}",
err.message
);
}
#[test]
fn extract_json_path_index_out_of_bounds() {
let json = r#"{"arr": [1, 2]}"#;
let err = extract_json_path(json, "arr.5").unwrap_err();
assert!(
err.message.contains("out of bounds"),
"error should mention out of bounds: {}",
err.message
);
}
#[test]
fn extract_json_path_string_not_json() {
let json = r#"{"name": "plain text"}"#;
let err = extract_json_path(json, "name.sub").unwrap_err();
assert!(
err.message.contains("not valid JSON"),
"error should mention invalid JSON: {}",
err.message
);
}
#[test]
fn extract_json_path_multi_level() {
let inner_inner = serde_json::json!({"rows": [{"x": 10}]});
let inner = serde_json::json!({"payload": inner_inner.to_string()});
let outer = serde_json::json!({"wrapper": inner.to_string()});
let out = extract_json_path(&outer.to_string(), "wrapper.payload.rows").unwrap();
let arr: Vec<serde_json::Value> = serde_json::from_str(&out).unwrap();
assert_eq!(arr.len(), 1);
assert_eq!(arr[0]["x"], 10);
}
#[test]
fn extract_json_path_then_ingest() {
let te = TestEngine::new_ephemeral();
let inner = serde_json::json!({
"status": "success",
"query_result": {
"results": [
{"id": 1, "msg": "hello"},
{"id": 2, "msg": "world"},
]
}
});
let wrapper = serde_json::json!({
"content": [{"type": "text", "text": inner.to_string()}]
});
let extracted =
extract_json_path(&wrapper.to_string(), "content.0.text.query_result.results").unwrap();
let opts = IngestOptions {
table: "splunk_data".into(),
mode: "replace".into(),
schema_override: None,
merge_key: None,
target_db: None,
};
let result = ingest_json(&te.engine, &extracted, &opts).unwrap();
assert_eq!(result.rows, 2);
let rows = te
.engine
.execute_query_to_json("SELECT * FROM splunk_data ORDER BY id")
.unwrap();
assert_eq!(rows.len(), 2);
assert_eq!(rows[0]["msg"], "hello");
assert_eq!(rows[1]["msg"], "world");
}
#[test]
fn ingest_json_merge_basic() {
let te = TestEngine::new_ephemeral();
let initial = r#"[
{"id": 1, "name": "Alice"},
{"id": 2, "name": "Bob"},
{"id": 3, "name": "Carol"}
]"#;
let updates = r#"[
{"id": 2, "name": "Bob Jr."},
{"id": 3, "name": "Carol Updated"},
{"id": 4, "name": "Dave"}
]"#;
let opts_replace = IngestOptions {
table: "users".into(),
mode: "replace".into(),
schema_override: None,
merge_key: None,
target_db: None,
};
ingest_json(&te.engine, initial, &opts_replace).unwrap();
let opts_merge = IngestOptions {
table: "users".into(),
mode: "merge".into(),
schema_override: None,
merge_key: Some(vec!["id".into()]),
target_db: None,
};
let merge_result = ingest_json(&te.engine, updates, &opts_merge).unwrap();
assert!(
!merge_result.stats.schema_changed,
"schema_changed must be false for a row-only merge"
);
let rows = te
.engine
.execute_query_to_json("SELECT * FROM users ORDER BY id")
.unwrap();
assert_eq!(
rows.len(),
4,
"expected 3 originals minus 2 replaced + 3 merged = 4"
);
assert_eq!(rows[0]["name"], "Alice"); assert_eq!(rows[1]["name"], "Bob Jr."); assert_eq!(rows[2]["name"], "Carol Updated"); assert_eq!(rows[3]["name"], "Dave"); }
#[test]
fn ingest_json_merge_adds_new_column() {
let te = TestEngine::new_ephemeral();
let initial = r#"[{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}]"#;
let with_host = r#"[
{"id": 2, "name": "Bob", "host": "host-2"},
{"id": 3, "name": "Carol", "host": "host-3"}
]"#;
let opts_replace = IngestOptions {
table: "t".into(),
mode: "replace".into(),
schema_override: None,
merge_key: None,
target_db: None,
};
ingest_json(&te.engine, initial, &opts_replace).unwrap();
let opts_merge = IngestOptions {
table: "t".into(),
mode: "merge".into(),
schema_override: None,
merge_key: Some(vec!["id".into()]),
target_db: None,
};
let merge_result = ingest_json(&te.engine, with_host, &opts_merge).unwrap();
assert!(
merge_result.stats.schema_changed,
"schema_changed must be true when merge ALTERs the target"
);
let cols = te.engine.column_metadata("t").unwrap();
assert!(
cols.iter().any(|c| c.name == "host"),
"expected `host` column added by ALTER TABLE; got {:?}",
cols.iter().map(|c| &c.name).collect::<Vec<_>>()
);
let rows = te
.engine
.execute_query_to_json("SELECT * FROM t ORDER BY id")
.unwrap();
assert_eq!(rows.len(), 3);
assert!(rows[0]["host"].is_null(), "old row's host must be NULL");
assert_eq!(rows[1]["host"], "host-2");
assert_eq!(rows[2]["host"], "host-3");
}
#[test]
fn ingest_json_merge_target_does_not_exist() {
let te = TestEngine::new_ephemeral();
let data = r#"[{"id": 1, "name": "Alice"}]"#;
let opts_merge = IngestOptions {
table: "fresh".into(),
mode: "merge".into(),
schema_override: None,
merge_key: Some(vec!["id".into()]),
target_db: None,
};
let result = ingest_json(&te.engine, data, &opts_merge).unwrap();
assert_eq!(result.rows, 1);
assert!(
result.stats.schema_changed,
"schema_changed must be true when merge creates a fresh target"
);
let rows = te
.engine
.execute_query_to_json("SELECT * FROM fresh")
.unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0]["name"], "Alice");
}
#[test]
fn ingest_json_merge_missing_key_param() {
let te = TestEngine::new_ephemeral();
let data = r#"[{"id": 1}]"#;
let opts = IngestOptions {
table: "t".into(),
mode: "merge".into(),
schema_override: None,
merge_key: None,
target_db: None,
};
let err = ingest_json(&te.engine, data, &opts).unwrap_err();
assert!(
err.message.to_lowercase().contains("merge_key"),
"error must mention merge_key; got: {}",
err.message
);
}
#[test]
fn ingest_json_merge_key_not_in_target() {
let te = TestEngine::new_ephemeral();
let opts_replace = IngestOptions {
table: "t".into(),
mode: "replace".into(),
schema_override: None,
merge_key: None,
target_db: None,
};
ingest_json(&te.engine, r#"[{"id": 1}]"#, &opts_replace).unwrap();
let opts_merge = IngestOptions {
table: "t".into(),
mode: "merge".into(),
schema_override: None,
merge_key: Some(vec!["not_a_col".into()]),
target_db: None,
};
let err = ingest_json(&te.engine, r#"[{"id": 2, "not_a_col": "x"}]"#, &opts_merge).unwrap_err();
assert!(
err.message.contains("not_a_col"),
"error must name the missing column; got: {}",
err.message
);
assert!(err.message.to_lowercase().contains("not in target"));
let count: i64 = te
.engine
.connection()
.execute_scalar_query("SELECT COUNT(*) FROM t")
.unwrap()
.unwrap();
assert_eq!(count, 1);
}
#[test]
fn ingest_json_merge_key_type_mismatch() {
let te = TestEngine::new_ephemeral();
let mut so = serde_json::Map::new();
so.insert("id".into(), serde_json::json!("BIGINT"));
let opts_replace = IngestOptions {
table: "t".into(),
mode: "replace".into(),
schema_override: Some(so),
merge_key: None,
target_db: None,
};
ingest_json(&te.engine, r#"[{"id": 1, "name": "a"}]"#, &opts_replace).unwrap();
let opts_merge = IngestOptions {
table: "t".into(),
mode: "merge".into(),
schema_override: None,
merge_key: Some(vec!["id".into()]),
target_db: None,
};
let err = ingest_json(&te.engine, r#"[{"id": "1", "name": "a"}]"#, &opts_merge).unwrap_err();
assert!(
err.message.to_lowercase().contains("type mismatch"),
"error must mention type mismatch; got: {}",
err.message
);
}
#[test]
fn ingest_json_merge_existing_column_type_mismatch() {
let te = TestEngine::new_ephemeral();
let mut so = serde_json::Map::new();
so.insert("score".into(), serde_json::json!("DOUBLE PRECISION"));
let opts_replace = IngestOptions {
table: "t".into(),
mode: "replace".into(),
schema_override: Some(so),
merge_key: None,
target_db: None,
};
ingest_json(&te.engine, r#"[{"id": 1, "score": 99.5}]"#, &opts_replace).unwrap();
let opts_merge = IngestOptions {
table: "t".into(),
mode: "merge".into(),
schema_override: None,
merge_key: Some(vec!["id".into()]),
target_db: None,
};
let err = ingest_json(
&te.engine,
r#"[{"id": 1, "score": "not a number"}]"#,
&opts_merge,
)
.unwrap_err();
assert!(
err.message.to_lowercase().contains("type mismatch"),
"error must mention type mismatch; got: {}",
err.message
);
}
#[test]
fn ingest_json_merge_no_orphan_tmp_on_failure() {
let te = TestEngine::new_ephemeral();
let opts_replace = IngestOptions {
table: "t".into(),
mode: "replace".into(),
schema_override: None,
merge_key: None,
target_db: None,
};
ingest_json(&te.engine, r#"[{"id": 1}]"#, &opts_replace).unwrap();
let opts_merge = IngestOptions {
table: "t".into(),
mode: "merge".into(),
schema_override: None,
merge_key: Some(vec!["bogus_key".into()]),
target_db: None,
};
let _ = ingest_json(&te.engine, r#"[{"id": 2, "bogus_key": "x"}]"#, &opts_merge);
let table_rows = te
.engine
.execute_query_to_json("SELECT table_name FROM \"$catalog\".\"public\".\"$tables\"")
.or_else(|_| {
te.engine.describe_tables().map(|tables| {
tables
.into_iter()
.map(|v| serde_json::json!({ "table_name": v["name"] }))
.collect::<Vec<_>>()
})
})
.unwrap();
let has_orphan = table_rows.iter().any(|v| {
v["table_name"]
.as_str()
.is_some_and(|s| s.starts_with("__hyperdb_merge_"))
});
assert!(
!has_orphan,
"merge failure must clean up its temp table; found orphan in: {table_rows:?}"
);
}
#[test]
fn ingest_json_merge_multi_key() {
let te = TestEngine::new_ephemeral();
let initial = r#"[
{"region": "us", "year": 2025, "amount": 100},
{"region": "us", "year": 2026, "amount": 200},
{"region": "eu", "year": 2025, "amount": 300},
{"region": "eu", "year": 2026, "amount": 400}
]"#;
let opts_replace = IngestOptions {
table: "sales".into(),
mode: "replace".into(),
schema_override: None,
merge_key: None,
target_db: None,
};
ingest_json(&te.engine, initial, &opts_replace).unwrap();
let updates = r#"[
{"region": "us", "year": 2026, "amount": 999},
{"region": "eu", "year": 2027, "amount": 500}
]"#;
let opts_merge = IngestOptions {
table: "sales".into(),
mode: "merge".into(),
schema_override: None,
merge_key: Some(vec!["region".into(), "year".into()]),
target_db: None,
};
ingest_json(&te.engine, updates, &opts_merge).unwrap();
let rows = te
.engine
.execute_query_to_json("SELECT * FROM sales ORDER BY region, year")
.unwrap();
assert_eq!(rows.len(), 5, "4 initial - 1 replaced + 2 new = 5");
assert_eq!(rows[0]["region"], "eu");
assert_eq!(rows[0]["year"], 2025);
assert_eq!(rows[0]["amount"], 300);
assert_eq!(rows[1]["amount"], 400);
assert_eq!(rows[2]["year"], 2027);
assert_eq!(rows[2]["amount"], 500);
assert_eq!(rows[3]["region"], "us");
assert_eq!(rows[3]["year"], 2025);
assert_eq!(rows[3]["amount"], 100);
assert_eq!(rows[4]["amount"], 999);
}
#[test]
fn ingest_json_merge_type_canonicalization_does_not_false_reject() {
let te = TestEngine::new_ephemeral();
let mut so_int = serde_json::Map::new();
so_int.insert("id".into(), serde_json::json!("INT"));
let opts_replace = IngestOptions {
table: "t".into(),
mode: "replace".into(),
schema_override: Some(so_int),
merge_key: None,
target_db: None,
};
ingest_json(&te.engine, r#"[{"id": 1, "name": "a"}]"#, &opts_replace).unwrap();
let cols = te.engine.column_metadata("t").unwrap();
let id_col = cols.iter().find(|c| c.name == "id").unwrap();
assert!(
id_col.hyper_type.eq_ignore_ascii_case("INTEGER"),
"expected canonicalized 'INTEGER' (or 'integer'); got {:?}",
id_col.hyper_type
);
let mut so_integer = serde_json::Map::new();
so_integer.insert("id".into(), serde_json::json!("INTEGER"));
let opts_merge = IngestOptions {
table: "t".into(),
mode: "merge".into(),
schema_override: Some(so_integer),
merge_key: Some(vec!["id".into()]),
target_db: None,
};
let result = ingest_json(
&te.engine,
r#"[{"id": 1, "name": "updated"}, {"id": 2, "name": "new"}]"#,
&opts_merge,
);
assert!(
result.is_ok(),
"merge must not false-reject equivalent types; got: {:?}",
result.err()
);
let rows = te
.engine
.execute_query_to_json("SELECT * FROM t ORDER BY id")
.unwrap();
assert_eq!(rows.len(), 2);
assert_eq!(rows[0]["name"], "updated");
assert_eq!(rows[1]["name"], "new");
}
#[test]
fn ingest_csv_merge_basic() {
let te = TestEngine::new_ephemeral();
let initial = "id,name\n1,Alice\n2,Bob\n3,Carol\n";
let updates = "id,name\n2,Bob Jr.\n3,Carol Updated\n4,Dave\n";
let opts_replace = IngestOptions {
table: "users_csv".into(),
mode: "replace".into(),
schema_override: None,
merge_key: None,
target_db: None,
};
ingest_csv(&te.engine, initial, &opts_replace).unwrap();
let opts_merge = IngestOptions {
table: "users_csv".into(),
mode: "merge".into(),
schema_override: None,
merge_key: Some(vec!["id".into()]),
target_db: None,
};
let merge_result = ingest_csv(&te.engine, updates, &opts_merge).unwrap();
assert!(
!merge_result.stats.schema_changed,
"row-only merge must leave schema_changed false"
);
let rows = te
.engine
.execute_query_to_json("SELECT * FROM users_csv ORDER BY id")
.unwrap();
assert_eq!(rows.len(), 4);
assert_eq!(rows[0]["name"], "Alice"); assert_eq!(rows[1]["name"], "Bob Jr."); assert_eq!(rows[2]["name"], "Carol Updated"); assert_eq!(rows[3]["name"], "Dave"); }