use std::sync::Arc;
use async_trait::async_trait;
use datafusion::catalog::{SchemaProvider, TableProvider};
use datafusion::datasource::object_store::ObjectStoreUrl;
use datafusion::error::Result as DataFusionResult;
use crate::metadata_provider::MetadataProvider;
use crate::path_resolver::resolve_path;
use crate::table::DuckLakeTable;
#[cfg(feature = "write")]
use crate::metadata_writer::{ColumnDef, MetadataWriter, WriteMode, validate_name};
#[cfg(feature = "write")]
use datafusion::error::DataFusionError;
#[cfg(feature = "write")]
fn validate_table_name(name: &str) -> DataFusionResult<()> {
validate_name(name, "Table").map_err(|e| DataFusionError::External(Box::new(e)))?;
if name.contains('/') || name.contains('\\') || name.contains("..") {
return Err(DataFusionError::Plan(format!(
"Invalid table name '{}': must not contain path separators or '..'",
name
)));
}
if name.chars().all(|c| c == '.') {
return Err(DataFusionError::Plan(format!(
"Invalid table name '{}': must not be only dots",
name
)));
}
Ok(())
}
#[derive(Debug)]
pub struct DuckLakeSchema {
schema_id: i64,
schema_name: String,
object_store_url: Arc<ObjectStoreUrl>,
provider: Arc<dyn MetadataProvider>,
snapshot_id: i64,
schema_path: String,
row_lineage: bool,
#[cfg(feature = "write")]
writer: Option<Arc<dyn MetadataWriter>>,
}
impl DuckLakeSchema {
pub fn new(
schema_id: i64,
schema_name: impl Into<String>,
provider: Arc<dyn MetadataProvider>,
snapshot_id: i64, object_store_url: Arc<ObjectStoreUrl>,
schema_path: String,
) -> Self {
Self {
schema_id,
schema_name: schema_name.into(),
provider,
snapshot_id,
object_store_url,
schema_path,
row_lineage: false,
#[cfg(feature = "write")]
writer: None,
}
}
pub fn with_row_lineage(mut self, enabled: bool) -> Self {
self.row_lineage = enabled;
self
}
#[cfg(feature = "write")]
pub fn with_writer(mut self, writer: Arc<dyn MetadataWriter>) -> Self {
self.writer = Some(writer);
self
}
}
#[async_trait]
impl SchemaProvider for DuckLakeSchema {
fn table_names(&self) -> Vec<String> {
self.provider
.list_tables(self.schema_id, self.snapshot_id)
.inspect_err(|e| {
tracing::error!(
error = %e,
schema_id = %self.schema_id,
snapshot_id = %self.snapshot_id,
schema_name = %self.schema_name,
"Failed to list tables from catalog"
)
})
.unwrap_or_default()
.into_iter()
.map(|t| t.table_name)
.collect()
}
async fn table(&self, name: &str) -> DataFusionResult<Option<Arc<dyn TableProvider>>> {
match self
.provider
.get_table_by_name(self.schema_id, name, self.snapshot_id)
{
Ok(Some(meta)) => {
let table_path = resolve_path(&self.schema_path, &meta.path, meta.path_is_relative)
.map_err(|e| datafusion::error::DataFusionError::External(Box::new(e)))?;
let table = DuckLakeTable::new(
meta.table_id,
meta.table_name.clone(),
self.provider.clone(),
self.snapshot_id, self.object_store_url.clone(),
table_path,
)
.map_err(|e| datafusion::error::DataFusionError::External(Box::new(e)))?
.with_row_lineage(self.row_lineage);
#[cfg(feature = "write")]
let table = if let Some(writer) = self.writer.as_ref() {
table.with_writer(self.schema_name.clone(), Arc::clone(writer))
} else {
table
};
Ok(Some(Arc::new(table) as Arc<dyn TableProvider>))
},
Ok(None) => Ok(None),
Err(e) => Err(datafusion::error::DataFusionError::External(Box::new(e))),
}
}
fn table_exist(&self, name: &str) -> bool {
self.provider
.table_exists(self.schema_id, name, self.snapshot_id)
.unwrap_or(false)
}
#[cfg(feature = "write")]
fn register_table(
&self,
name: String,
table: Arc<dyn TableProvider>,
) -> DataFusionResult<Option<Arc<dyn TableProvider>>> {
validate_table_name(&name)?;
let writer = self.writer.as_ref().ok_or_else(|| {
DataFusionError::Plan(
"Schema is read-only. Use DuckLakeCatalog::with_writer() to enable writes."
.to_string(),
)
})?;
let arrow_schema = table.schema();
let columns: Vec<ColumnDef> = arrow_schema
.fields()
.iter()
.map(|field| {
ColumnDef::from_arrow(field.name(), field.data_type(), field.is_nullable())
.map_err(|e| DataFusionError::External(Box::new(e)))
})
.collect::<DataFusionResult<Vec<_>>>()?;
let setup = writer
.begin_write_transaction(&self.schema_name, &name, &columns, WriteMode::Replace)
.map_err(|e| DataFusionError::External(Box::new(e)))?;
let committed = writer
.publish_snapshot(
setup.table_id,
&self.schema_name,
&name,
setup.snapshot_id,
WriteMode::Replace,
setup.base_snapshot_id,
&columns,
&setup.column_ids,
)
.map_err(|e| DataFusionError::External(Box::new(e)))?;
let table_path = resolve_path(&self.schema_path, &name, true)
.map_err(|e| DataFusionError::External(Box::new(e)))?;
let writable_table = DuckLakeTable::new(
committed.table_id,
name,
self.provider.clone(),
committed.snapshot_id,
self.object_store_url.clone(),
table_path,
)
.map_err(|e| DataFusionError::External(Box::new(e)))?
.with_writer(self.schema_name.clone(), Arc::clone(writer));
Ok(Some(Arc::new(writable_table) as Arc<dyn TableProvider>))
}
}
#[cfg(all(test, feature = "write"))]
mod tests {
use super::*;
#[test]
fn test_validate_table_name_valid() {
assert!(validate_table_name("users").is_ok());
assert!(validate_table_name("my_table").is_ok());
assert!(validate_table_name("Table123").is_ok());
assert!(validate_table_name("a").is_ok());
}
#[test]
fn test_validate_table_name_empty() {
let result = validate_table_name("");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("cannot be empty"));
}
#[test]
fn test_validate_table_name_path_traversal() {
let result = validate_table_name("../etc/passwd");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("path separators"));
let result = validate_table_name("..\\windows\\system32");
assert!(result.is_err());
let result = validate_table_name("foo..bar");
assert!(result.is_err());
let result = validate_table_name("foo/bar");
assert!(result.is_err());
}
#[test]
fn test_validate_table_name_only_dots() {
assert!(validate_table_name(".").is_err());
assert!(validate_table_name("..").is_err());
assert!(validate_table_name("...").is_err());
}
#[test]
fn test_validate_table_name_control_chars() {
assert!(validate_table_name("table\0name").is_err());
assert!(validate_table_name("table\nname").is_err());
assert!(validate_table_name("table\tname").is_err());
assert!(validate_table_name("\x7Ftable").is_err());
}
}