#![expect(
clippy::cast_possible_wrap,
reason = "test data (row counts) bounded by test parameters; usize→i64 wrap is unreachable"
)]
mod common;
use common::TestEngine;
use hyperdb_mcp::ingest::IngestOptions;
use hyperdb_mcp::ingest_arrow::{ingest_arrow_ipc_file, ingest_parquet_file};
use std::sync::Arc;
fn create_test_parquet(path: &str) {
use arrow::array::{Int32Array, StringArray};
use arrow::datatypes::{DataType, Field, Schema};
use arrow::record_batch::RecordBatch;
use parquet::arrow::ArrowWriter;
use std::fs::File;
let schema = Arc::new(Schema::new(vec![
Field::new("id", DataType::Int32, false),
Field::new("name", DataType::Utf8, true),
]));
let batch = RecordBatch::try_new(
Arc::clone(&schema),
vec![
Arc::new(Int32Array::from(vec![1, 2, 3])),
Arc::new(StringArray::from(vec![Some("Alice"), Some("Bob"), None])),
],
)
.unwrap();
let file = File::create(path).unwrap();
let mut writer = ArrowWriter::try_new(file, schema, None).unwrap();
writer.write(&batch).unwrap();
writer.close().unwrap();
}
fn create_test_arrow_ipc(path: &str) {
use arrow::array::{Float64Array, Int32Array};
use arrow::datatypes::{DataType, Field, Schema};
use arrow::ipc::writer::FileWriter;
use arrow::record_batch::RecordBatch;
use std::fs::File;
let schema = Arc::new(Schema::new(vec![
Field::new("x", DataType::Int32, false),
Field::new("y", DataType::Float64, false),
]));
let batch = RecordBatch::try_new(
Arc::clone(&schema),
vec![
Arc::new(Int32Array::from(vec![10, 20])),
Arc::new(Float64Array::from(vec![1.5, 2.5])),
],
)
.unwrap();
let file = File::create(path).unwrap();
let mut writer = FileWriter::try_new(file, &schema).unwrap();
writer.write(&batch).unwrap();
writer.finish().unwrap();
}
#[test]
fn ingest_parquet() {
let te = TestEngine::new_ephemeral();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.parquet");
let path_str = path.to_str().unwrap();
create_test_parquet(path_str);
let opts = IngestOptions {
table: "pq_data".into(),
mode: "replace".into(),
schema_override: None,
merge_key: None,
target_db: None,
};
let result = ingest_parquet_file(&te.engine, path_str, &opts).unwrap();
assert_eq!(result.rows, 3);
let rows = te
.engine
.execute_query_to_json("SELECT * FROM pq_data ORDER BY id")
.unwrap();
assert_eq!(rows.len(), 3);
assert_eq!(rows[0]["name"], "Alice");
assert!(rows[2]["name"].is_null());
}
#[test]
fn ingest_parquet_decimal128_not_null_preserves_values() {
use arrow::array::{Decimal128Array, Int64Array};
use arrow::datatypes::{DataType, Field, Schema};
use arrow::record_batch::RecordBatch;
use parquet::arrow::ArrowWriter;
use std::fs::File;
let te = TestEngine::new_ephemeral();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("decimal.parquet");
let path_str = path.to_str().unwrap();
let schema = Arc::new(Schema::new(vec![
Field::new("id", DataType::Int64, false),
Field::new("amount", DataType::Decimal128(15, 2), false),
]));
let amount = Decimal128Array::from(vec![12345i128, -67890i128, 0i128])
.with_precision_and_scale(15, 2)
.unwrap();
let batch = RecordBatch::try_new(
Arc::clone(&schema),
vec![Arc::new(Int64Array::from(vec![1, 2, 3])), Arc::new(amount)],
)
.unwrap();
let file = File::create(path_str).unwrap();
let mut writer = ArrowWriter::try_new(file, schema, None).unwrap();
writer.write(&batch).unwrap();
writer.close().unwrap();
let opts = IngestOptions {
table: "decimal_data".into(),
mode: "replace".into(),
schema_override: None,
merge_key: None,
target_db: None,
};
let result = ingest_parquet_file(&te.engine, path_str, &opts).unwrap();
assert_eq!(result.rows, 3);
let amount_col = result
.schema
.iter()
.find(|c| c.name == "amount")
.expect("amount column");
assert_eq!(amount_col.hyper_type, "NUMERIC(15, 2)");
assert!(!amount_col.nullable);
let rows = te
.engine
.execute_query_to_json("SELECT id, amount::TEXT AS amount FROM decimal_data ORDER BY id")
.unwrap();
assert_eq!(rows.len(), 3);
assert_eq!(rows[0]["amount"], "123.45");
assert_eq!(rows[1]["amount"], "-678.90");
assert_eq!(rows[2]["amount"], "0.00");
}
#[test]
fn ingest_parquet_reports_accurate_row_count_above_131072() {
use arrow::array::Int64Array;
use arrow::datatypes::{DataType, Field, Schema};
use arrow::record_batch::RecordBatch;
use parquet::arrow::ArrowWriter;
use std::fs::File;
const ROW_COUNT: usize = 200_000;
let te = TestEngine::new_ephemeral();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("big.parquet");
let path_str = path.to_str().unwrap();
let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int64, false)]));
let ids: Vec<i64> = (0..ROW_COUNT as i64).collect();
let batch =
RecordBatch::try_new(Arc::clone(&schema), vec![Arc::new(Int64Array::from(ids))]).unwrap();
let file = File::create(path_str).unwrap();
let mut writer = ArrowWriter::try_new(file, schema, None).unwrap();
writer.write(&batch).unwrap();
writer.close().unwrap();
let opts = IngestOptions {
table: "big_table".into(),
mode: "replace".into(),
schema_override: None,
merge_key: None,
target_db: None,
};
let result = ingest_parquet_file(&te.engine, path_str, &opts).unwrap();
assert_eq!(
result.rows, ROW_COUNT as u64,
"IngestResult.rows was truncated — count must be read outside the CTAS transaction"
);
let rows = te
.engine
.execute_query_to_json("SELECT COUNT(*) AS n FROM big_table")
.unwrap();
assert_eq!(rows[0]["n"], ROW_COUNT);
}
#[test]
fn ingest_parquet_append_adds_to_existing_rows() {
let te = TestEngine::new_ephemeral();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("append.parquet");
let path_str = path.to_str().unwrap();
create_test_parquet(path_str);
let opts_replace = IngestOptions {
table: "append_data".into(),
mode: "replace".into(),
schema_override: None,
merge_key: None,
target_db: None,
};
let r1 = ingest_parquet_file(&te.engine, path_str, &opts_replace).unwrap();
assert_eq!(r1.rows, 3);
let opts_append = IngestOptions {
table: "append_data".into(),
mode: "append".into(),
schema_override: None,
merge_key: None,
target_db: None,
};
let r2 = ingest_parquet_file(&te.engine, path_str, &opts_append).unwrap();
assert_eq!(r2.rows, 3);
let rows = te
.engine
.execute_query_to_json("SELECT COUNT(*) AS n FROM append_data")
.unwrap();
assert_eq!(rows[0]["n"], 6);
}
#[test]
fn ingest_parquet_applies_schema_override() {
let te = TestEngine::new_ephemeral();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("override.parquet");
let path_str = path.to_str().unwrap();
create_test_parquet(path_str);
let mut override_map = serde_json::Map::new();
override_map.insert("id".to_string(), serde_json::Value::String("BIGINT".into()));
let opts = IngestOptions {
table: "override_data".into(),
mode: "replace".into(),
schema_override: Some(override_map),
merge_key: None,
target_db: None,
};
let result = ingest_parquet_file(&te.engine, path_str, &opts).unwrap();
assert_eq!(result.rows, 3);
let id_col = result
.schema
.iter()
.find(|c| c.name == "id")
.expect("id column");
assert_eq!(id_col.hyper_type, "BIGINT");
te.engine
.execute_command("INSERT INTO override_data (id, name) VALUES (9999999999, 'big')")
.expect("insert of value beyond i32::MAX should succeed with BIGINT column");
let rows = te
.engine
.execute_query_to_json("SELECT id FROM override_data WHERE name = 'big'")
.unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0]["id"], 9999999999i64);
}
#[test]
fn ingest_arrow_ipc() {
let te = TestEngine::new_ephemeral();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.arrow");
let path_str = path.to_str().unwrap();
create_test_arrow_ipc(path_str);
let opts = IngestOptions {
table: "arrow_data".into(),
mode: "replace".into(),
schema_override: None,
merge_key: None,
target_db: None,
};
let result = ingest_arrow_ipc_file(&te.engine, path_str, &opts).unwrap();
assert_eq!(result.rows, 2);
let rows = te
.engine
.execute_query_to_json("SELECT * FROM arrow_data ORDER BY x")
.unwrap();
assert_eq!(rows.len(), 2);
}
#[test]
fn ingest_arrow_ipc_decimal128_not_null_preserves_values() {
use arrow::array::{Decimal128Array, Int64Array};
use arrow::datatypes::{DataType, Field, Schema};
use arrow::ipc::writer::FileWriter;
use arrow::record_batch::RecordBatch;
use std::fs::File;
let te = TestEngine::new_ephemeral();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("decimal.arrow");
let path_str = path.to_str().unwrap();
let schema = Arc::new(Schema::new(vec![
Field::new("id", DataType::Int64, false),
Field::new("amount", DataType::Decimal128(15, 2), false),
]));
let amount = Decimal128Array::from(vec![12345i128, -67890i128, 0i128])
.with_precision_and_scale(15, 2)
.unwrap();
let batch = RecordBatch::try_new(
Arc::clone(&schema),
vec![Arc::new(Int64Array::from(vec![1, 2, 3])), Arc::new(amount)],
)
.unwrap();
let file = File::create(path_str).unwrap();
let mut writer = FileWriter::try_new(file, &schema).unwrap();
writer.write(&batch).unwrap();
writer.finish().unwrap();
let opts = IngestOptions {
table: "ipc_decimal".into(),
mode: "replace".into(),
schema_override: None,
merge_key: None,
target_db: None,
};
let result = ingest_arrow_ipc_file(&te.engine, path_str, &opts).unwrap();
assert_eq!(result.rows, 3);
let amount_col = result
.schema
.iter()
.find(|c| c.name == "amount")
.expect("amount column");
assert_eq!(amount_col.hyper_type, "NUMERIC(15, 2)");
assert!(!amount_col.nullable);
let rows = te
.engine
.execute_query_to_json("SELECT id, amount::TEXT AS amount FROM ipc_decimal ORDER BY id")
.unwrap();
assert_eq!(rows.len(), 3);
assert_eq!(rows[0]["amount"], "123.45");
assert_eq!(rows[1]["amount"], "-678.90");
assert_eq!(rows[2]["amount"], "0.00");
}
#[test]
fn ingest_arrow_ipc_append_adds_to_existing_rows() {
let te = TestEngine::new_ephemeral();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("append.arrow");
let path_str = path.to_str().unwrap();
create_test_arrow_ipc(path_str);
let opts_replace = IngestOptions {
table: "ipc_append".into(),
mode: "replace".into(),
schema_override: None,
merge_key: None,
target_db: None,
};
let r1 = ingest_arrow_ipc_file(&te.engine, path_str, &opts_replace).unwrap();
assert_eq!(r1.rows, 2);
let opts_append = IngestOptions {
table: "ipc_append".into(),
mode: "append".into(),
schema_override: None,
merge_key: None,
target_db: None,
};
let r2 = ingest_arrow_ipc_file(&te.engine, path_str, &opts_append).unwrap();
assert_eq!(r2.rows, 2);
let rows = te
.engine
.execute_query_to_json("SELECT COUNT(*) AS n FROM ipc_append")
.unwrap();
assert_eq!(rows[0]["n"], 4);
}
#[test]
fn ingest_arrow_ipc_rejects_schema_override() {
let te = TestEngine::new_ephemeral();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("override.arrow");
let path_str = path.to_str().unwrap();
create_test_arrow_ipc(path_str);
let mut override_map = serde_json::Map::new();
override_map.insert("x".to_string(), serde_json::Value::String("BIGINT".into()));
let opts = IngestOptions {
table: "ipc_override".into(),
mode: "replace".into(),
schema_override: Some(override_map),
merge_key: None,
target_db: None,
};
let Err(err) = ingest_arrow_ipc_file(&te.engine, path_str, &opts) else {
panic!("override on IPC should be rejected")
};
let msg = err.to_string();
assert!(
msg.contains("Schema overrides are not supported for Arrow IPC"),
"error should explain why override was rejected, got: {msg}"
);
let rows = te
.engine
.execute_query_to_json(
"SELECT 1 AS present FROM pg_catalog.pg_tables WHERE tablename = 'ipc_override'",
)
.unwrap();
assert!(
rows.is_empty(),
"table should not exist after rejected override"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn load_files_runs_parallel_parquet_ingests() {
use arrow::array::Int64Array;
use arrow::datatypes::{DataType, Field, Schema};
use arrow::record_batch::RecordBatch;
use hyperdb_api::pool::{create_pool, PoolConfig};
use hyperdb_api::CreateMode;
use hyperdb_mcp::ingest_arrow::ingest_parquet_file_async;
use parquet::arrow::ArrowWriter;
use std::fs::File;
let te = TestEngine::new_ephemeral();
let dir = tempfile::tempdir().unwrap();
let write_parquet = |name: &str, n: usize| {
let path = dir.path().join(name);
let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int64, false)]));
let ids: Vec<i64> = (0..n as i64).collect();
let batch =
RecordBatch::try_new(Arc::clone(&schema), vec![Arc::new(Int64Array::from(ids))])
.unwrap();
let file = File::create(&path).unwrap();
let mut w = ArrowWriter::try_new(file, schema, None).unwrap();
w.write(&batch).unwrap();
w.close().unwrap();
path.to_str().unwrap().to_string()
};
let path_a = write_parquet("a.parquet", 5_000);
let path_b = write_parquet("b.parquet", 7_000);
let endpoint = te.engine.hyperd_endpoint().unwrap();
let workspace = te.engine.ephemeral_path().to_string_lossy().to_string();
let pool = Arc::new(
create_pool(
PoolConfig::new(endpoint, workspace)
.create_mode(CreateMode::DoNotCreate)
.max_size(2),
)
.unwrap(),
);
let run = |table: &str, path: String| {
let pool = Arc::clone(&pool);
let table = table.to_string();
async move {
let conn = pool.get().await.unwrap();
let opts = IngestOptions {
table,
mode: "replace".into(),
schema_override: None,
merge_key: None,
target_db: None,
};
ingest_parquet_file_async(&conn, &path, &opts)
.await
.unwrap()
}
};
let (res_a, res_b) = tokio::join!(run("table_a", path_a), run("table_b", path_b));
assert_eq!(res_a.rows, 5_000);
assert_eq!(res_b.rows, 7_000);
let a = te
.engine
.execute_query_to_json("SELECT COUNT(*) AS n FROM table_a")
.unwrap();
assert_eq!(a[0]["n"], 5_000);
let b = te
.engine
.execute_query_to_json("SELECT COUNT(*) AS n FROM table_b")
.unwrap();
assert_eq!(b[0]["n"], 7_000);
}
#[test]
fn ingest_arrow_ipc_accepts_stream_format() {
use arrow::array::{Float64Array, Int32Array};
use arrow::datatypes::{DataType, Field, Schema};
use arrow::ipc::writer::StreamWriter;
use arrow::record_batch::RecordBatch;
use std::fs::File;
let te = TestEngine::new_ephemeral();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("stream.arrow");
let path_str = path.to_str().unwrap();
let schema = Arc::new(Schema::new(vec![
Field::new("x", DataType::Int32, false),
Field::new("y", DataType::Float64, false),
]));
let batch = RecordBatch::try_new(
Arc::clone(&schema),
vec![
Arc::new(Int32Array::from(vec![10, 20, 30])),
Arc::new(Float64Array::from(vec![1.5, 2.5, 3.5])),
],
)
.unwrap();
let file = File::create(path_str).unwrap();
let mut writer = StreamWriter::try_new(file, &schema).unwrap();
writer.write(&batch).unwrap();
writer.finish().unwrap();
let opts = IngestOptions {
table: "stream_data".into(),
mode: "replace".into(),
schema_override: None,
merge_key: None,
target_db: None,
};
let result = ingest_arrow_ipc_file(&te.engine, path_str, &opts).unwrap();
assert_eq!(result.rows, 3);
let rows = te
.engine
.execute_query_to_json("SELECT x FROM stream_data ORDER BY x")
.unwrap();
assert_eq!(rows.len(), 3);
assert_eq!(rows[0]["x"], 10);
assert_eq!(rows[2]["x"], 30);
}
#[test]
fn ingest_parquet_merge_basic() {
let te = TestEngine::new_ephemeral();
let dir = tempfile::tempdir().unwrap();
let initial_path = dir.path().join("initial.parquet");
create_test_parquet(initial_path.to_str().unwrap());
let opts_replace = IngestOptions {
table: "pq_merge".into(),
mode: "replace".into(),
schema_override: None,
merge_key: None,
target_db: None,
};
let r1 =
ingest_parquet_file(&te.engine, initial_path.to_str().unwrap(), &opts_replace).unwrap();
assert_eq!(r1.rows, 3);
use arrow::array::{Int32Array, StringArray};
use arrow::datatypes::{DataType, Field, Schema};
use arrow::record_batch::RecordBatch;
use parquet::arrow::ArrowWriter;
use std::fs::File;
let schema = Arc::new(Schema::new(vec![
Field::new("id", DataType::Int32, false),
Field::new("name", DataType::Utf8, true),
]));
let batch = RecordBatch::try_new(
Arc::clone(&schema),
vec![
Arc::new(Int32Array::from(vec![2, 4])),
Arc::new(StringArray::from(vec![Some("Bob Updated"), Some("Dave")])),
],
)
.unwrap();
let merge_path = dir.path().join("merge.parquet");
let file = File::create(&merge_path).unwrap();
let mut writer = ArrowWriter::try_new(file, schema, None).unwrap();
writer.write(&batch).unwrap();
writer.close().unwrap();
let opts_merge = IngestOptions {
table: "pq_merge".into(),
mode: "merge".into(),
schema_override: None,
merge_key: Some(vec!["id".into()]),
target_db: None,
};
ingest_parquet_file(&te.engine, merge_path.to_str().unwrap(), &opts_merge).unwrap();
let rows = te
.engine
.execute_query_to_json("SELECT * FROM pq_merge ORDER BY id")
.unwrap();
assert_eq!(rows.len(), 4);
assert_eq!(rows[0]["name"], "Alice"); assert_eq!(rows[1]["name"], "Bob Updated"); assert!(rows[2]["name"].is_null()); assert_eq!(rows[3]["name"], "Dave"); }
#[test]
fn ingest_arrow_ipc_merge_basic() {
let te = TestEngine::new_ephemeral();
let dir = tempfile::tempdir().unwrap();
let initial_path = dir.path().join("initial.arrow");
create_test_arrow_ipc(initial_path.to_str().unwrap());
let opts_replace = IngestOptions {
table: "ipc_merge".into(),
mode: "replace".into(),
schema_override: None,
merge_key: None,
target_db: None,
};
let r1 =
ingest_arrow_ipc_file(&te.engine, initial_path.to_str().unwrap(), &opts_replace).unwrap();
assert_eq!(r1.rows, 2);
use arrow::array::{Float64Array, Int32Array};
use arrow::datatypes::{DataType, Field, Schema};
use arrow::ipc::writer::FileWriter;
use arrow::record_batch::RecordBatch;
use std::fs::File;
let schema = Arc::new(Schema::new(vec![
Field::new("x", DataType::Int32, false),
Field::new("y", DataType::Float64, false),
]));
let batch = RecordBatch::try_new(
Arc::clone(&schema),
vec![
Arc::new(Int32Array::from(vec![20, 30])),
Arc::new(Float64Array::from(vec![9.9, 3.5])),
],
)
.unwrap();
let merge_path = dir.path().join("merge.arrow");
let file = File::create(&merge_path).unwrap();
let mut writer = FileWriter::try_new(file, &schema).unwrap();
writer.write(&batch).unwrap();
writer.finish().unwrap();
let opts_merge = IngestOptions {
table: "ipc_merge".into(),
mode: "merge".into(),
schema_override: None,
merge_key: Some(vec!["x".into()]),
target_db: None,
};
ingest_arrow_ipc_file(&te.engine, merge_path.to_str().unwrap(), &opts_merge).unwrap();
let rows = te
.engine
.execute_query_to_json("SELECT * FROM ipc_merge ORDER BY x")
.unwrap();
assert_eq!(rows.len(), 3);
assert_eq!(rows[0]["x"], 10);
assert_eq!(rows[0]["y"], 1.5);
assert_eq!(rows[1]["x"], 20);
assert_eq!(rows[1]["y"], 9.9); assert_eq!(rows[2]["x"], 30); assert_eq!(rows[2]["y"], 3.5);
}