#![cfg(feature = "metadata-duckdb")]
use std::sync::Arc;
use datafusion::error::Result as DataFusionResult;
use datafusion::prelude::*;
use datafusion_ducklake::{DuckLakeCatalog, DuckdbMetadataProvider};
use tempfile::TempDir;
fn create_catalog_with_negative_file_size(catalog_path: &std::path::Path) -> anyhow::Result<()> {
let conn = duckdb::Connection::open_in_memory()?;
conn.execute("INSTALL ducklake;", [])?;
conn.execute("LOAD ducklake;", [])?;
let ducklake_path = format!("ducklake:{}", catalog_path.display());
conn.execute(&format!("ATTACH '{}' AS test_catalog;", ducklake_path), [])?;
conn.execute(
"CREATE TABLE test_catalog.items (id INT, name VARCHAR);",
[],
)?;
conn.execute(
"INSERT INTO test_catalog.items VALUES (1, 'Widget'), (2, 'Gadget');",
[],
)?;
conn.execute("DETACH test_catalog;", [])?;
let meta_conn = duckdb::Connection::open(catalog_path)?;
meta_conn.execute("UPDATE ducklake_data_file SET file_size_bytes = -1;", [])?;
Ok(())
}
fn create_catalog_with_negative_footer_size(catalog_path: &std::path::Path) -> anyhow::Result<()> {
let conn = duckdb::Connection::open_in_memory()?;
conn.execute("INSTALL ducklake;", [])?;
conn.execute("LOAD ducklake;", [])?;
let ducklake_path = format!("ducklake:{}", catalog_path.display());
conn.execute(&format!("ATTACH '{}' AS test_catalog;", ducklake_path), [])?;
conn.execute(
"CREATE TABLE test_catalog.items (id INT, name VARCHAR);",
[],
)?;
conn.execute(
"INSERT INTO test_catalog.items VALUES (1, 'Widget'), (2, 'Gadget');",
[],
)?;
conn.execute("DETACH test_catalog;", [])?;
let meta_conn = duckdb::Connection::open(catalog_path)?;
meta_conn.execute("UPDATE ducklake_data_file SET footer_size = -42;", [])?;
Ok(())
}
fn create_catalog(path: &str) -> DataFusionResult<Arc<DuckLakeCatalog>> {
let provider = DuckdbMetadataProvider::new(path)
.map_err(|e| datafusion::error::DataFusionError::External(Box::new(e)))?;
let catalog = DuckLakeCatalog::new(provider)
.map_err(|e| datafusion::error::DataFusionError::External(Box::new(e)))?;
Ok(Arc::new(catalog))
}
#[tokio::test]
async fn test_negative_file_size_produces_clear_error() -> DataFusionResult<()> {
let temp_dir =
TempDir::new().map_err(|e| datafusion::error::DataFusionError::External(Box::new(e)))?;
let catalog_path = temp_dir.path().join("neg_size.ducklake");
create_catalog_with_negative_file_size(&catalog_path)
.map_err(|e| datafusion::error::DataFusionError::External(e.into()))?;
let catalog = create_catalog(&catalog_path.to_string_lossy())?;
let ctx = SessionContext::new();
ctx.register_catalog("ducklake", catalog);
let result = ctx
.sql("SELECT * FROM ducklake.main.items")
.await?
.collect()
.await;
assert!(
result.is_err(),
"Query should fail with negative file_size_bytes"
);
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("Invalid file_size_bytes"),
"Error should mention invalid file_size_bytes, got: {}",
err_msg
);
assert!(
err_msg.contains("-1"),
"Error should contain the negative value, got: {}",
err_msg
);
Ok(())
}
#[tokio::test]
async fn test_negative_footer_size_is_gracefully_skipped() -> DataFusionResult<()> {
let temp_dir =
TempDir::new().map_err(|e| datafusion::error::DataFusionError::External(Box::new(e)))?;
let catalog_path = temp_dir.path().join("neg_footer.ducklake");
create_catalog_with_negative_footer_size(&catalog_path)
.map_err(|e| datafusion::error::DataFusionError::External(e.into()))?;
let catalog = create_catalog(&catalog_path.to_string_lossy())?;
let ctx = SessionContext::new();
ctx.register_catalog("ducklake", catalog);
let df = ctx.sql("SELECT * FROM ducklake.main.items").await?;
let batches = df.collect().await?;
let total_rows: usize = batches.iter().map(|b| b.num_rows()).sum();
assert_eq!(
total_rows, 2,
"Should still return all rows when footer_size is negative"
);
Ok(())
}