use std::any::Any;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use crate::Result;
use crate::column_rename::ColumnRenameExec;
use crate::delete_filter::DeleteFilterExec;
use crate::metadata_provider::{
DuckLakeFileData, DuckLakeTableColumn, DuckLakeTableFile, MetadataProvider,
};
use crate::path_resolver::resolve_path;
use crate::types::{
build_arrow_schema, build_read_schema_with_field_id_mapping, extract_parquet_field_ids,
};
#[cfg(feature = "write")]
use crate::insert_exec::DuckLakeInsertExec;
#[cfg(feature = "write")]
use crate::metadata_writer::{MetadataWriter, WriteMode};
#[cfg(feature = "encryption")]
use crate::encryption::EncryptionFactoryBuilder;
use arrow::array::{Array, Int64Array};
use arrow::datatypes::{DataType, Field, Schema, SchemaRef};
use arrow::record_batch::RecordBatch;
use async_trait::async_trait;
use datafusion::catalog::{Session, TableProvider};
use datafusion::datasource::listing::PartitionedFile;
use datafusion::datasource::physical_plan::{FileGroup, FileScanConfigBuilder, ParquetSource};
use datafusion::datasource::source::DataSourceExec;
use datafusion::error::{DataFusionError, Result as DataFusionResult};
use datafusion::execution::object_store::ObjectStoreUrl;
#[cfg(feature = "write")]
use datafusion::logical_expr::dml::InsertOp;
use datafusion::logical_expr::{Expr, TableProviderFilterPushDown, TableType};
use datafusion::physical_plan::ExecutionPlan;
use futures::StreamExt;
use object_store::path::Path as ObjectPath;
use parquet::arrow::ParquetRecordBatchStreamBuilder;
use parquet::arrow::async_reader::ParquetObjectReader;
use tokio::sync::OnceCell;
#[cfg(feature = "encryption")]
use datafusion::execution::parquet_encryption::EncryptionFactory;
pub const DELETE_FILE_PATH_COL: &str = "file_path";
pub const DELETE_POS_COL: &str = "pos";
pub(crate) fn validated_file_size(file_size_bytes: i64, file_path: &str) -> DataFusionResult<u64> {
u64::try_from(file_size_bytes).map_err(|_| {
DataFusionError::Execution(format!(
"Invalid file_size_bytes ({}) for file '{}': value must be non-negative",
file_size_bytes, file_path
))
})
}
pub(crate) fn validated_record_count(record_count: i64, file_path: &str) -> DataFusionResult<u64> {
u64::try_from(record_count).map_err(|_| {
DataFusionError::Execution(format!(
"Invalid record_count ({}) for file '{}': value must be non-negative",
record_count, file_path
))
})
}
pub fn delete_file_schema() -> SchemaRef {
Arc::new(Schema::new(vec![
Field::new(DELETE_FILE_PATH_COL, DataType::Utf8, false),
Field::new(DELETE_POS_COL, DataType::Int64, false),
]))
}
type SchemaMappingCache = (SchemaRef, HashMap<String, String>);
pub struct DuckLakeTable {
#[allow(dead_code)]
table_id: i64,
table_name: String,
#[allow(dead_code)]
provider: Arc<dyn MetadataProvider>,
object_store_url: Arc<ObjectStoreUrl>,
table_path: String,
schema: SchemaRef,
columns: Vec<DuckLakeTableColumn>,
table_files: Vec<DuckLakeTableFile>,
schema_mapping_cache: OnceCell<SchemaMappingCache>,
#[cfg(feature = "encryption")]
encryption_factory: Option<Arc<dyn EncryptionFactory>>,
#[cfg(feature = "write")]
schema_name: Option<String>,
#[cfg(feature = "write")]
writer: Option<Arc<dyn MetadataWriter>>,
}
impl std::fmt::Debug for DuckLakeTable {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DuckLakeTable")
.field("table_id", &self.table_id)
.field("table_name", &self.table_name)
.field("table_path", &self.table_path)
.field("schema", &self.schema)
.field("columns", &self.columns)
.field("table_files", &self.table_files)
.finish_non_exhaustive()
}
}
impl DuckLakeTable {
pub fn new(
table_id: i64,
table_name: impl Into<String>,
provider: Arc<dyn MetadataProvider>,
snapshot_id: i64, object_store_url: Arc<ObjectStoreUrl>,
table_path: String,
) -> Result<Self> {
let columns = provider.get_table_structure(table_id)?;
let schema = Arc::new(build_arrow_schema(&columns)?);
let table_files = provider.get_table_files_for_select(table_id, snapshot_id)?;
#[cfg(feature = "encryption")]
let encryption_factory = {
let mut builder = EncryptionFactoryBuilder::new();
for table_file in &table_files {
let resolved_path = resolve_path(
&table_path,
&table_file.file.path,
table_file.file.path_is_relative,
)?;
builder.add_file(&resolved_path, table_file.file.encryption_key.as_deref());
if let Some(ref delete_file) = table_file.delete_file {
let resolved_delete_path =
resolve_path(&table_path, &delete_file.path, delete_file.path_is_relative)?;
builder.add_file(&resolved_delete_path, delete_file.encryption_key.as_deref());
}
}
let factory = builder.build();
if factory.has_encrypted_files() {
Some(Arc::new(factory) as Arc<dyn EncryptionFactory>)
} else {
None
}
};
Ok(Self {
table_id,
table_name: table_name.into(),
provider,
object_store_url,
table_path,
schema,
columns,
table_files,
#[cfg(feature = "encryption")]
encryption_factory,
schema_mapping_cache: OnceCell::new(),
#[cfg(feature = "write")]
schema_name: None,
#[cfg(feature = "write")]
writer: None,
})
}
fn resolve_file_path(&self, file: &DuckLakeFileData) -> DataFusionResult<String> {
resolve_path(&self.table_path, &file.path, file.path_is_relative)
.map_err(|e| DataFusionError::External(Box::new(e)))
}
fn create_parquet_source(&self, schema: SchemaRef) -> ParquetSource {
#[cfg(feature = "encryption")]
if let Some(ref factory) = self.encryption_factory {
return ParquetSource::new(schema).with_encryption_factory(Arc::clone(factory));
}
ParquetSource::new(schema)
}
async fn get_schema_mapping(
&self,
state: &dyn Session,
) -> DataFusionResult<&SchemaMappingCache> {
self.schema_mapping_cache
.get_or_try_init(|| async {
let Some(first_file) = self.table_files.first() else {
return Ok((self.schema.clone(), HashMap::new()));
};
let resolved_path = self.resolve_file_path(&first_file.file)?;
let object_store = state
.runtime_env()
.object_store(self.object_store_url.as_ref())?;
let object_path = ObjectPath::from(resolved_path.as_str());
let reader = ParquetObjectReader::new(object_store, object_path);
#[cfg(feature = "encryption")]
let builder = {
use parquet::arrow::arrow_reader::ArrowReaderOptions;
let options = if let Some(ref key) = first_file.file.encryption_key {
if !key.is_empty() {
let key_bytes =
crate::encryption::DuckLakeEncryptionFactory::decode_key(key)?;
let decryption_props =
parquet::encryption::decrypt::FileDecryptionProperties::builder(
key_bytes,
)
.build()
.map_err(|e| {
DataFusionError::Execution(format!(
"Failed to create decryption properties: {}",
e
))
})?;
ArrowReaderOptions::new()
.with_file_decryption_properties(decryption_props)
} else {
ArrowReaderOptions::new()
}
} else {
ArrowReaderOptions::new()
};
ParquetRecordBatchStreamBuilder::new_with_options(reader, options)
.await
.map_err(|e| DataFusionError::External(Box::new(e)))?
};
#[cfg(not(feature = "encryption"))]
let builder = ParquetRecordBatchStreamBuilder::new(reader)
.await
.map_err(|e| DataFusionError::External(Box::new(e)))?;
let field_id_map = extract_parquet_field_ids(builder.metadata());
if field_id_map.is_empty() {
return Ok((self.schema.clone(), HashMap::new()));
}
let (read_schema, name_mapping) =
build_read_schema_with_field_id_mapping(&self.columns, &field_id_map)
.map_err(|e| DataFusionError::External(Box::new(e)))?;
Ok((Arc::new(read_schema), name_mapping))
})
.await
}
async fn read_delete_file_positions(
&self,
state: &dyn Session,
delete_file: &DuckLakeFileData,
) -> DataFusionResult<HashSet<i64>> {
let delete_schema = delete_file_schema();
let resolved_delete_path = self.resolve_file_path(delete_file)?;
let mut pf = PartitionedFile::new(
&resolved_delete_path,
validated_file_size(delete_file.file_size_bytes, &resolved_delete_path)?,
);
if let Some(footer_size) = delete_file.footer_size
&& footer_size > 0
&& let Ok(hint) = usize::try_from(footer_size)
{
pf = pf.with_metadata_size_hint(hint);
}
let file_scan_config = FileScanConfigBuilder::new(
self.object_store_url.as_ref().clone(),
Arc::new(self.create_parquet_source(delete_schema)),
)
.with_file_group(FileGroup::new(vec![pf]))
.build();
let exec = DataSourceExec::from_data_source(file_scan_config);
let task_ctx = state.task_ctx();
let stream = exec.execute(0, task_ctx)?;
let batches: Vec<RecordBatch> = stream
.collect::<Vec<_>>()
.await
.into_iter()
.collect::<DataFusionResult<Vec<_>>>()
.map_err(|e| {
if is_object_store_not_found(&e) {
DataFusionError::Execution(format!(
"Delete file '{}' referenced in catalog metadata was not found. This may indicate catalog corruption or that the file was deleted outside of DuckLake.",
resolved_delete_path
))
} else {
e
}
})?;
let mut positions = HashSet::new();
for batch in batches {
extract_deleted_positions_from_batch(&batch, &mut positions)?;
}
Ok(positions)
}
async fn build_exec_for_files_without_deletes(
&self,
state: &dyn Session,
files: &[&DuckLakeTableFile],
projection: Option<&Vec<usize>>,
limit: Option<usize>,
) -> DataFusionResult<Arc<dyn ExecutionPlan>> {
let (read_schema, name_mapping) = self.get_schema_mapping(state).await?;
let partitioned_files: Vec<PartitionedFile> = files
.iter()
.map(|table_file| {
let resolved_path = self.resolve_file_path(&table_file.file)?;
let mut pf = PartitionedFile::new(
&resolved_path,
validated_file_size(table_file.file.file_size_bytes, &resolved_path)?,
);
if let Some(footer_size) = table_file.file.footer_size
&& footer_size > 0
&& let Ok(hint) = usize::try_from(footer_size)
{
pf = pf.with_metadata_size_hint(hint);
}
Ok(pf)
})
.collect::<DataFusionResult<Vec<_>>>()?;
let mut builder = FileScanConfigBuilder::new(
self.object_store_url.as_ref().clone(),
Arc::new(self.create_parquet_source(read_schema.clone())),
)
.with_limit(limit)
.with_file_group(FileGroup::new(partitioned_files));
if let Some(proj) = projection {
builder = builder.with_projection_indices(Some(proj.clone()))?;
}
let file_scan_config = builder.build();
let parquet_exec: Arc<dyn ExecutionPlan> =
DataSourceExec::from_data_source(file_scan_config);
if !name_mapping.is_empty() {
let output_schema = match projection {
Some(indices) => Arc::new(self.schema.project(indices)?),
None => self.schema.clone(),
};
Ok(Arc::new(ColumnRenameExec::new(
parquet_exec,
output_schema,
name_mapping.clone(),
)))
} else {
Ok(parquet_exec)
}
}
#[cfg(feature = "write")]
pub fn with_writer(mut self, schema_name: String, writer: Arc<dyn MetadataWriter>) -> Self {
self.schema_name = Some(schema_name);
self.writer = Some(writer);
self
}
async fn build_exec_for_file_with_deletes(
&self,
state: &dyn Session,
table_file: &DuckLakeTableFile,
projection: Option<&Vec<usize>>,
limit: Option<usize>,
) -> DataFusionResult<Arc<dyn ExecutionPlan>> {
let (read_schema, name_mapping) = self.get_schema_mapping(state).await?;
let resolved_path = self.resolve_file_path(&table_file.file)?;
let mut pf = PartitionedFile::new(
&resolved_path,
validated_file_size(table_file.file.file_size_bytes, &resolved_path)?,
);
if let Some(footer_size) = table_file.file.footer_size
&& footer_size > 0
&& let Ok(hint) = usize::try_from(footer_size)
{
pf = pf.with_metadata_size_hint(hint);
}
let mut builder = FileScanConfigBuilder::new(
self.object_store_url.as_ref().clone(),
Arc::new(self.create_parquet_source(read_schema.clone())),
)
.with_limit(limit)
.with_file_group(FileGroup::new(vec![pf]));
if let Some(proj) = projection {
builder = builder.with_projection_indices(Some(proj.clone()))?;
}
let file_scan_config = builder.build();
let parquet_exec: Arc<dyn ExecutionPlan> =
DataSourceExec::from_data_source(file_scan_config);
let exec_after_delete: Arc<dyn ExecutionPlan> =
if let Some(ref delete_file) = table_file.delete_file {
let deleted_positions = self.read_delete_file_positions(state, delete_file).await?;
if !deleted_positions.is_empty() {
Arc::new(DeleteFilterExec::new(
parquet_exec,
table_file.file.path.clone(),
Arc::new(deleted_positions),
))
} else {
parquet_exec
}
} else {
parquet_exec
};
if !name_mapping.is_empty() {
let output_schema = match projection {
Some(indices) => Arc::new(self.schema.project(indices)?),
None => self.schema.clone(),
};
Ok(Arc::new(ColumnRenameExec::new(
exec_after_delete,
output_schema,
name_mapping.clone(),
)))
} else {
Ok(exec_after_delete)
}
}
}
#[async_trait]
impl TableProvider for DuckLakeTable {
fn as_any(&self) -> &dyn Any {
self
}
fn schema(&self) -> SchemaRef {
Arc::clone(&self.schema)
}
fn table_type(&self) -> TableType {
TableType::Base
}
fn supports_filters_pushdown(
&self,
filters: &[&Expr],
) -> DataFusionResult<Vec<TableProviderFilterPushDown>> {
Ok(filters
.iter()
.map(|_| TableProviderFilterPushDown::Inexact)
.collect())
}
async fn scan(
&self,
state: &dyn Session,
projection: Option<&Vec<usize>>,
_filters: &[Expr],
limit: Option<usize>,
) -> DataFusionResult<Arc<dyn ExecutionPlan>> {
let (files_with_deletes, files_without_deletes): (Vec<_>, Vec<_>) = self
.table_files
.iter()
.partition(|tf| tf.delete_file.is_some());
let mut execs: Vec<Arc<dyn ExecutionPlan>> = Vec::new();
if !files_without_deletes.is_empty() {
let exec = self
.build_exec_for_files_without_deletes(
state,
&files_without_deletes,
projection,
limit,
)
.await?;
execs.push(exec);
}
for table_file in files_with_deletes {
let exec = self
.build_exec_for_file_with_deletes(state, table_file, projection, limit)
.await?;
execs.push(exec);
}
if execs.is_empty() {
use datafusion::physical_plan::empty::EmptyExec;
let projected_schema = match projection {
Some(indices) => Arc::new(self.schema.project(indices)?),
None => self.schema.clone(),
};
return Ok(Arc::new(EmptyExec::new(projected_schema)));
}
combine_execution_plans(execs)
}
#[cfg(feature = "write")]
async fn insert_into(
&self,
_state: &dyn Session,
input: Arc<dyn ExecutionPlan>,
insert_op: InsertOp,
) -> DataFusionResult<Arc<dyn ExecutionPlan>> {
let writer = self.writer.as_ref().ok_or_else(|| {
DataFusionError::Plan(
"Table is read-only. Use DuckLakeCatalog::with_writer() to enable writes."
.to_string(),
)
})?;
let schema_name = self.schema_name.as_ref().ok_or_else(|| {
DataFusionError::Internal("Schema name not set for writable table".to_string())
})?;
let write_mode = match insert_op {
InsertOp::Append => WriteMode::Append,
InsertOp::Overwrite | InsertOp::Replace => WriteMode::Replace,
};
Ok(Arc::new(DuckLakeInsertExec::new(
input,
Arc::clone(writer),
schema_name.clone(),
self.table_name.clone(),
self.schema(),
write_mode,
self.object_store_url.clone(),
)))
}
}
fn combine_execution_plans(
execs: Vec<Arc<dyn ExecutionPlan>>,
) -> DataFusionResult<Arc<dyn ExecutionPlan>> {
if execs.len() == 1 {
Ok(execs.into_iter().next().unwrap())
} else {
use datafusion::physical_plan::union::UnionExec;
UnionExec::try_new(execs)
}
}
fn extract_deleted_positions_from_batch(
batch: &RecordBatch,
positions: &mut HashSet<i64>,
) -> DataFusionResult<()> {
let schema = batch.schema();
let pos_idx = schema.index_of(DELETE_POS_COL)?;
let pos_array = batch
.column(pos_idx)
.as_any()
.downcast_ref::<Int64Array>()
.ok_or_else(|| {
DataFusionError::Internal(format!("{} column not found or wrong type", DELETE_POS_COL))
})?;
for i in 0..batch.num_rows() {
if !pos_array.is_null(i) {
positions.insert(pos_array.value(i));
}
}
Ok(())
}
fn is_object_store_not_found(err: &DataFusionError) -> bool {
if let DataFusionError::ObjectStore(os_err) = err {
return matches!(&**os_err, object_store::Error::NotFound { .. });
}
let mut source = std::error::Error::source(err);
while let Some(e) = source {
if let Some(os_err) = e.downcast_ref::<object_store::Error>() {
return matches!(os_err, object_store::Error::NotFound { .. });
}
source = e.source();
}
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validated_file_size_positive() {
assert_eq!(validated_file_size(0, "test.parquet").unwrap(), 0);
assert_eq!(validated_file_size(1024, "test.parquet").unwrap(), 1024);
assert_eq!(
validated_file_size(i64::MAX, "test.parquet").unwrap(),
i64::MAX as u64
);
}
#[test]
fn test_validated_file_size_negative() {
let err = validated_file_size(-1, "data/test.parquet").unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("-1"),
"Error should contain the negative value: {}",
msg
);
assert!(
msg.contains("data/test.parquet"),
"Error should contain the file path: {}",
msg
);
}
#[test]
fn test_validated_file_size_large_negative() {
let err = validated_file_size(i64::MIN, "bad.parquet").unwrap_err();
let msg = err.to_string();
assert!(msg.contains("bad.parquet"));
assert!(msg.contains(&i64::MIN.to_string()));
}
#[test]
fn test_validated_record_count_positive() {
assert_eq!(validated_record_count(0, "test.parquet").unwrap(), 0);
assert_eq!(validated_record_count(100, "test.parquet").unwrap(), 100);
assert_eq!(
validated_record_count(i64::MAX, "test.parquet").unwrap(),
i64::MAX as u64
);
}
#[test]
fn test_validated_record_count_negative() {
let err = validated_record_count(-1, "data/test.parquet").unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("-1"),
"Error should contain the negative value: {}",
msg
);
assert!(
msg.contains("data/test.parquet"),
"Error should contain the file path: {}",
msg
);
assert!(
msg.contains("record_count"),
"Error should mention record_count: {}",
msg
);
}
#[test]
fn test_validated_record_count_large_negative() {
let err = validated_record_count(i64::MIN, "bad.parquet").unwrap_err();
let msg = err.to_string();
assert!(msg.contains("bad.parquet"));
assert!(msg.contains(&i64::MIN.to_string()));
}
}