use crate::engine::Engine;
use crate::error::{ErrorCode, McpError};
use crate::ingest::IngestResult;
use crate::schema::ColumnSchema;
use crate::stats::{IngestStats, StatsTimer};
use hyperdb_api::escape_string_literal;
use std::path::Path;
#[derive(Debug, Clone)]
pub struct IcebergIngestOptions {
pub table: String,
pub mode: String,
pub metadata_filename: Option<String>,
pub version_as_of: Option<i64>,
}
#[must_use]
pub fn build_iceberg_ingest_sql(
table: &str,
path: &str,
opts: &IcebergIngestOptions,
is_replace: bool,
) -> String {
let quoted_table = format!("\"{}\"", table.replace('"', "\"\""));
let quoted_path = escape_string_literal(path);
let mut external_args = vec![quoted_path, "format => 'iceberg'".to_string()];
if let Some(name) = &opts.metadata_filename {
external_args.push(format!(
"metadata_filename => {}",
escape_string_literal(name)
));
}
if let Some(version) = opts.version_as_of {
external_args.push(format!("version_as_of => {version}"));
}
let external = format!("external({})", external_args.join(", "));
if is_replace {
format!("CREATE TABLE {quoted_table} AS SELECT * FROM {external}")
} else {
format!("INSERT INTO {quoted_table} SELECT * FROM {external}")
}
}
fn resolve_iceberg_path(path: &str) -> Result<String, McpError> {
let p = Path::new(path);
if !p.exists() {
return Err(McpError::new(
ErrorCode::FileNotFound,
format!("Iceberg path does not exist: {path}"),
));
}
if !p.is_dir() {
return Err(McpError::new(
ErrorCode::UnsupportedFormat,
format!(
"Iceberg path must be a directory (the table root with a `metadata/` subdir), not a file: {path}"
),
));
}
Ok(std::fs::canonicalize(p)
.map_err(|e| {
McpError::new(
ErrorCode::FileNotFound,
format!("Cannot resolve Iceberg path {path}: {e}"),
)
})?
.to_string_lossy()
.into_owned())
}
fn describe_table(engine: &Engine, table: &str) -> Result<Vec<ColumnSchema>, McpError> {
let desc = engine.describe_table(table)?;
let cols = desc.get("columns").and_then(|v| v.as_array());
Ok(cols
.map(|arr| {
arr.iter()
.map(|c| ColumnSchema {
name: c
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
hyper_type: c
.get("type")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
nullable: c
.get("nullable")
.and_then(serde_json::Value::as_bool)
.unwrap_or(true),
})
.collect()
})
.unwrap_or_default())
}
fn count_rows(engine: &Engine, table: &str) -> Result<u64, McpError> {
let quoted = format!("\"{}\"", table.replace('"', "\"\""));
let sql = format!("SELECT COUNT(*) FROM {quoted}");
let rows = engine.execute_query_to_json(&sql)?;
rows.first()
.and_then(|r| r.get("count"))
.and_then(serde_json::Value::as_u64)
.ok_or_else(|| {
McpError::new(
ErrorCode::InternalError,
"Could not read row count after Iceberg ingest",
)
})
}
pub fn ingest_iceberg_table(
engine: &Engine,
path: &str,
opts: &IcebergIngestOptions,
) -> Result<IngestResult, McpError> {
let timer = StatsTimer::start();
let absolute_path = resolve_iceberg_path(path)?;
let is_replace = opts.mode != "append";
let sql = build_iceberg_ingest_sql(&opts.table, &absolute_path, opts, is_replace);
let affected = engine.execute_in_transaction(|engine| {
if is_replace {
let quoted_table = format!("\"{}\"", opts.table.replace('"', "\"\""));
engine.execute_command(&format!("DROP TABLE IF EXISTS {quoted_table}"))?;
}
engine.execute_command(&sql)
})?;
let row_count = if is_replace {
count_rows(engine, &opts.table)?
} else {
affected
};
let schema = describe_table(engine, &opts.table).unwrap_or_default();
let elapsed = timer.elapsed_ms();
let stats = IngestStats {
operation: "load_iceberg".into(),
rows: row_count,
elapsed_ms: elapsed,
bytes_read: 0,
bytes_stored: 0,
schema_inference_ms: Some(0),
table: opts.table.clone(),
file_format: Some("iceberg".into()),
warning: None,
schema_changed: false,
};
Ok(IngestResult {
rows: row_count,
schema,
stats,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builds_replace_sql_with_minimal_options() {
let opts = IcebergIngestOptions {
table: "my_table".into(),
mode: "replace".into(),
metadata_filename: None,
version_as_of: None,
};
let sql = build_iceberg_ingest_sql("my_table", "/abs/table", &opts, true);
assert_eq!(
sql,
"CREATE TABLE \"my_table\" AS SELECT * FROM external('/abs/table', format => 'iceberg')"
);
}
#[test]
fn builds_append_sql_with_metadata_filename() {
let opts = IcebergIngestOptions {
table: "t".into(),
mode: "append".into(),
metadata_filename: Some("v2.metadata.json".into()),
version_as_of: None,
};
let sql = build_iceberg_ingest_sql("t", "/abs/t", &opts, false);
assert_eq!(
sql,
"INSERT INTO \"t\" SELECT * FROM external('/abs/t', format => 'iceberg', metadata_filename => 'v2.metadata.json')"
);
}
#[test]
fn builds_sql_with_version_as_of() {
let opts = IcebergIngestOptions {
table: "t".into(),
mode: "replace".into(),
metadata_filename: None,
version_as_of: Some(7),
};
let sql = build_iceberg_ingest_sql("t", "/abs/t", &opts, true);
assert_eq!(
sql,
"CREATE TABLE \"t\" AS SELECT * FROM external('/abs/t', format => 'iceberg', version_as_of => 7)"
);
}
#[test]
fn builds_sql_with_both_metadata_and_version() {
let opts = IcebergIngestOptions {
table: "t".into(),
mode: "replace".into(),
metadata_filename: Some("v3.metadata.json".into()),
version_as_of: Some(42),
};
let sql = build_iceberg_ingest_sql("t", "/abs/t", &opts, true);
assert_eq!(
sql,
"CREATE TABLE \"t\" AS SELECT * FROM external('/abs/t', format => 'iceberg', metadata_filename => 'v3.metadata.json', version_as_of => 42)"
);
}
#[test]
fn escapes_single_quotes_in_path() {
let opts = IcebergIngestOptions {
table: "t".into(),
mode: "replace".into(),
metadata_filename: None,
version_as_of: None,
};
let sql = build_iceberg_ingest_sql("t", "/abs/it's/t", &opts, true);
assert!(
sql.contains("'/abs/it''s/t'"),
"single quote in path must be escaped; got: {sql}"
);
}
#[test]
fn escapes_single_quotes_in_metadata_filename() {
let opts = IcebergIngestOptions {
table: "t".into(),
mode: "replace".into(),
metadata_filename: Some("v1.metadata'.json".into()),
version_as_of: None,
};
let sql = build_iceberg_ingest_sql("t", "/abs/t", &opts, true);
assert!(
sql.contains("metadata_filename => 'v1.metadata''.json'"),
"single quote in metadata_filename must be escaped; got: {sql}"
);
}
#[test]
fn escapes_quotes_in_table_name() {
let opts = IcebergIngestOptions {
table: "weird\"name".into(),
mode: "replace".into(),
metadata_filename: None,
version_as_of: None,
};
let sql = build_iceberg_ingest_sql("weird\"name", "/abs/t", &opts, true);
assert!(
sql.contains("\"weird\"\"name\""),
"double-quote in table name must be escaped; got: {sql}"
);
}
}