#![cfg(all(feature = "write-sqlite", feature = "metadata-sqlite"))]
use std::sync::Arc;
use arrow::array::{Array, Int32Array, UInt64Array};
use arrow::datatypes::{DataType, Field, Schema};
use arrow::record_batch::RecordBatch;
use datafusion::prelude::*;
use object_store::local::LocalFileSystem;
use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
use tempfile::TempDir;
use datafusion_ducklake::types::extract_parquet_field_ids;
use datafusion_ducklake::{
DuckLakeCatalog, DuckLakeTableWriter, MetadataWriter, SqliteMetadataProvider,
SqliteMetadataWriter,
};
use sqlx::sqlite::SqlitePool;
fn object_store() -> Arc<dyn object_store::ObjectStore> {
Arc::new(LocalFileSystem::new())
}
fn id_schema() -> Arc<Schema> {
Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)]))
}
fn id_batch(ids: &[i32]) -> RecordBatch {
RecordBatch::try_new(id_schema(), vec![Arc::new(Int32Array::from(ids.to_vec()))]).unwrap()
}
async fn new_writer(temp: &TempDir) -> SqliteMetadataWriter {
let db_path = temp.path().join("test.db");
let data_path = temp.path().join("data");
std::fs::create_dir_all(&data_path).unwrap();
let conn = format!("sqlite:{}?mode=rwc", db_path.display());
let writer = SqliteMetadataWriter::new_with_init(&conn).await.unwrap();
writer.set_data_path(data_path.to_str().unwrap()).unwrap();
writer
}
async fn writable_ctx(temp: &TempDir) -> SessionContext {
let conn = format!("sqlite:{}?mode=rwc", temp.path().join("test.db").display());
let writer = SqliteMetadataWriter::new(&conn).await.unwrap();
let provider = SqliteMetadataProvider::new(&conn).await.unwrap();
let catalog = DuckLakeCatalog::with_writer(Arc::new(provider), Arc::new(writer)).unwrap();
let ctx = SessionContext::new();
ctx.register_catalog("ducklake", Arc::new(catalog));
ctx
}
async fn read_ids(temp: &TempDir) -> Vec<i32> {
let conn = format!("sqlite:{}", temp.path().join("test.db").display());
let provider = SqliteMetadataProvider::new(&conn).await.unwrap();
let catalog = DuckLakeCatalog::new(provider).unwrap();
let ctx = SessionContext::new();
ctx.register_catalog("ducklake", Arc::new(catalog));
let batches = ctx
.sql("SELECT id FROM ducklake.main.t ORDER BY id")
.await
.unwrap()
.collect()
.await
.unwrap();
let mut ids = Vec::new();
for b in &batches {
let col = b.column(0).as_any().downcast_ref::<Int32Array>().unwrap();
for i in 0..b.num_rows() {
ids.push(col.value(i));
}
}
ids
}
async fn snapshot_count(temp: &TempDir) -> i64 {
let conn = format!("sqlite:{}", temp.path().join("test.db").display());
let pool = SqlitePool::connect(&conn).await.unwrap();
sqlx::query_scalar("SELECT COUNT(*) FROM ducklake_snapshot")
.fetch_one(&pool)
.await
.unwrap()
}
async fn run_delete(ctx: &SessionContext, sql: &str) -> u64 {
let batches = ctx.sql(sql).await.unwrap().collect().await.unwrap();
assert_eq!(batches.len(), 1, "DELETE returns a single count batch");
assert_eq!(batches[0].num_rows(), 1, "count batch has one row");
batches[0]
.column(0)
.as_any()
.downcast_ref::<UInt64Array>()
.expect("count column is UInt64")
.value(0)
}
#[tokio::test(flavor = "multi_thread")]
async fn delete_single_file_predicate() {
let temp = TempDir::new().unwrap();
let writer = Arc::new(new_writer(&temp).await);
DuckLakeTableWriter::new(writer, object_store())
.unwrap()
.write_table("main", "t", &[id_batch(&[1, 2, 3, 4])])
.await
.unwrap();
assert_eq!(read_ids(&temp).await, vec![1, 2, 3, 4], "baseline");
let ctx = writable_ctx(&temp).await;
let count = run_delete(&ctx, "DELETE FROM ducklake.main.t WHERE id = 2").await;
assert_eq!(count, 1, "one row deleted");
assert_eq!(read_ids(&temp).await, vec![1, 3, 4], "id 2 gone");
}
#[tokio::test(flavor = "multi_thread")]
async fn delete_multi_file_atomic() {
let temp = TempDir::new().unwrap();
let writer = Arc::new(new_writer(&temp).await);
let tw = DuckLakeTableWriter::new(writer, object_store()).unwrap();
tw.write_table("main", "t", &[id_batch(&[1, 2, 3])])
.await
.unwrap();
tw.append_table("main", "t", &[id_batch(&[4, 5, 6])])
.await
.unwrap();
assert_eq!(read_ids(&temp).await, vec![1, 2, 3, 4, 5, 6], "baseline");
let before = snapshot_count(&temp).await;
let ctx = writable_ctx(&temp).await;
let count = run_delete(
&ctx,
"DELETE FROM ducklake.main.t WHERE id = 2 OR id = 4 OR id = 6",
)
.await;
assert_eq!(count, 3, "three rows deleted across two files");
let after = snapshot_count(&temp).await;
assert_eq!(
after,
before + 1,
"multi-file DELETE must commit in exactly one new snapshot"
);
assert_eq!(
read_ids(&temp).await,
vec![1, 3, 5],
"survivors across files"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn delete_all_rows_no_where() {
let temp = TempDir::new().unwrap();
let writer = Arc::new(new_writer(&temp).await);
DuckLakeTableWriter::new(writer, object_store())
.unwrap()
.write_table("main", "t", &[id_batch(&[1, 2, 3, 4])])
.await
.unwrap();
let ctx = writable_ctx(&temp).await;
let count = run_delete(&ctx, "DELETE FROM ducklake.main.t").await;
assert_eq!(count, 4, "all four rows deleted");
assert_eq!(read_ids(&temp).await, Vec::<i32>::new(), "table empty");
}
#[tokio::test(flavor = "multi_thread")]
async fn delete_after_delete_is_cumulative() {
let temp = TempDir::new().unwrap();
let writer = Arc::new(new_writer(&temp).await);
DuckLakeTableWriter::new(writer, object_store())
.unwrap()
.write_table("main", "t", &[id_batch(&[1, 2, 3, 4])])
.await
.unwrap();
let ctx1 = writable_ctx(&temp).await;
let c1 = run_delete(&ctx1, "DELETE FROM ducklake.main.t WHERE id = 2").await;
assert_eq!(c1, 1);
assert_eq!(read_ids(&temp).await, vec![1, 3, 4], "after first delete");
let ctx2 = writable_ctx(&temp).await;
let c2 = run_delete(&ctx2, "DELETE FROM ducklake.main.t WHERE id = 4").await;
assert_eq!(c2, 1, "only the newly-matched row counts");
assert_eq!(
read_ids(&temp).await,
vec![1, 3],
"cumulative: id 2 stayed deleted, id 4 now deleted (no resurrection)"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn delete_already_deleted_is_noop() {
let temp = TempDir::new().unwrap();
let writer = Arc::new(new_writer(&temp).await);
DuckLakeTableWriter::new(writer, object_store())
.unwrap()
.write_table("main", "t", &[id_batch(&[1, 2, 3, 4])])
.await
.unwrap();
let ctx1 = writable_ctx(&temp).await;
assert_eq!(
run_delete(&ctx1, "DELETE FROM ducklake.main.t WHERE id = 2").await,
1
);
let snaps = snapshot_count(&temp).await;
let ctx2 = writable_ctx(&temp).await;
let c = run_delete(&ctx2, "DELETE FROM ducklake.main.t WHERE id = 2").await;
assert_eq!(c, 0, "nothing new to delete");
assert_eq!(
snapshot_count(&temp).await,
snaps,
"a no-op DELETE creates no snapshot"
);
assert_eq!(read_ids(&temp).await, vec![1, 3, 4]);
}
#[tokio::test(flavor = "multi_thread")]
async fn delete_no_match() {
let temp = TempDir::new().unwrap();
let writer = Arc::new(new_writer(&temp).await);
DuckLakeTableWriter::new(writer, object_store())
.unwrap()
.write_table("main", "t", &[id_batch(&[1, 2, 3])])
.await
.unwrap();
let ctx = writable_ctx(&temp).await;
let count = run_delete(&ctx, "DELETE FROM ducklake.main.t WHERE id = 999").await;
assert_eq!(count, 0);
assert_eq!(read_ids(&temp).await, vec![1, 2, 3], "unchanged");
}
#[tokio::test(flavor = "multi_thread")]
async fn delete_file_uses_duckdb_field_ids() {
let temp = TempDir::new().unwrap();
let writer = Arc::new(new_writer(&temp).await);
let tw = DuckLakeTableWriter::new(writer, object_store()).unwrap();
tw.write_table("main", "t", &[id_batch(&[1, 2, 3, 4])])
.await
.unwrap();
let info = tw
.write_delete_file("main", "t", "data-file.parquet", &[1, 3])
.await
.unwrap();
let del_path = temp
.path()
.join("data")
.join("main")
.join("t")
.join(&info.path);
let file = std::fs::File::open(&del_path).expect("delete parquet exists on disk");
let builder = ParquetRecordBatchReaderBuilder::try_new(file).unwrap();
let field_ids = extract_parquet_field_ids(builder.metadata().as_ref());
assert_eq!(
field_ids.get(&2_147_483_646).map(String::as_str),
Some("file_path"),
"file_path must use DuckDB's FILENAME field-id 2147483646, got {field_ids:?}"
);
assert_eq!(
field_ids.get(&2_147_483_645).map(String::as_str),
Some("pos"),
"pos must use DuckDB's ORDINAL field-id 2147483645, got {field_ids:?}"
);
assert!(
!field_ids.contains_key(&2_147_483_546) && !field_ids.contains_key(&2_147_483_545),
"must not use Iceberg positional-delete field-ids: {field_ids:?}"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn delete_truncate_repeat_same_ctx_no_spurious_snapshot() {
let temp = TempDir::new().unwrap();
let writer = Arc::new(new_writer(&temp).await);
DuckLakeTableWriter::new(writer, object_store())
.unwrap()
.write_table("main", "t", &[id_batch(&[1, 2, 3, 4])])
.await
.unwrap();
let before = snapshot_count(&temp).await;
let ctx = writable_ctx(&temp).await;
let c1 = run_delete(&ctx, "DELETE FROM ducklake.main.t").await;
assert_eq!(c1, 4, "first truncate removes all rows");
let after_first = snapshot_count(&temp).await;
assert_eq!(
after_first,
before + 1,
"first truncate commits exactly one snapshot"
);
let c2 = run_delete(&ctx, "DELETE FROM ducklake.main.t").await;
assert_eq!(c2, 0, "nothing left to truncate");
assert_eq!(
snapshot_count(&temp).await,
after_first,
"a no-op truncate must not create a snapshot"
);
assert_eq!(
read_ids(&temp).await,
Vec::<i32>::new(),
"table stays empty"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn delete_second_in_session_conflicts_without_resurrection() {
let temp = TempDir::new().unwrap();
let writer = Arc::new(new_writer(&temp).await);
DuckLakeTableWriter::new(writer, object_store())
.unwrap()
.write_table("main", "t", &[id_batch(&[1, 2, 3, 4])])
.await
.unwrap();
let ctx = writable_ctx(&temp).await;
assert_eq!(
run_delete(&ctx, "DELETE FROM ducklake.main.t WHERE id = 2").await,
1
);
assert_eq!(read_ids(&temp).await, vec![1, 3, 4], "id 2 deleted");
let err = ctx
.sql("DELETE FROM ducklake.main.t WHERE id = 4")
.await
.unwrap()
.collect()
.await
.expect_err("second in-session DELETE must conflict, not silently corrupt");
let msg = err.to_string();
assert!(
msg.contains("Re-open the catalog") && msg.contains("THIS session"),
"conflict message must explain the pinned-snapshot cause, got: {msg}"
);
assert_eq!(
read_ids(&temp).await,
vec![1, 3, 4],
"aborted DELETE must not resurrect id 2 nor delete id 4"
);
}