use crate::{DuckLakeError, Result};
pub const MAX_NAME_LENGTH: usize = 1024;
pub fn validate_name(name: &str, kind: &str) -> Result<()> {
if name.trim().is_empty() {
return Err(DuckLakeError::InvalidConfig(format!(
"{kind} name cannot be empty or whitespace-only"
)));
}
if let Some(pos) = name.find(|c: char| c.is_ascii_control()) {
let byte = name.as_bytes()[pos];
return Err(DuckLakeError::InvalidConfig(format!(
"{kind} name contains control character 0x{byte:02X} at position {pos}"
)));
}
if name.len() > MAX_NAME_LENGTH {
return Err(DuckLakeError::InvalidConfig(format!(
"{kind} name exceeds maximum length of {MAX_NAME_LENGTH} characters (got {})",
name.len()
)));
}
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WriteMode {
Replace,
Append,
}
use crate::types::{arrow_to_ducklake_type, ducklake_to_arrow_type};
use arrow::datatypes::DataType;
#[derive(Debug, Clone)]
pub struct ColumnDef {
pub(crate) name: String,
pub(crate) ducklake_type: String,
pub(crate) is_nullable: bool,
}
impl ColumnDef {
pub fn name(&self) -> &str {
&self.name
}
pub fn ducklake_type(&self) -> &str {
&self.ducklake_type
}
pub fn is_nullable(&self) -> bool {
self.is_nullable
}
pub fn new(
name: impl Into<String>,
ducklake_type: impl Into<String>,
is_nullable: bool,
) -> Result<Self> {
let name = name.into();
validate_name(&name, "Column")?;
let ducklake_type = ducklake_type.into();
ducklake_to_arrow_type(&ducklake_type)?;
Ok(Self {
name,
ducklake_type,
is_nullable,
})
}
pub fn from_arrow(
name: impl Into<String>,
data_type: &DataType,
is_nullable: bool,
) -> Result<Self> {
let name = name.into();
validate_name(&name, "Column")?;
let ducklake_type = arrow_to_ducklake_type(data_type)?;
Ok(Self {
name,
ducklake_type,
is_nullable,
})
}
}
#[derive(Debug, Clone)]
pub struct DataFileInfo {
pub path: String,
pub path_is_relative: bool,
pub file_size_bytes: i64,
pub footer_size: Option<i64>,
pub record_count: i64,
}
impl DataFileInfo {
pub fn new(path: impl Into<String>, file_size_bytes: i64, record_count: i64) -> Self {
assert!(
record_count >= 0,
"record_count must be non-negative, got {}",
record_count
);
Self {
path: path.into(),
path_is_relative: true,
file_size_bytes,
footer_size: None,
record_count,
}
}
pub fn with_footer_size(mut self, footer_size: i64) -> Self {
self.footer_size = Some(footer_size);
self
}
pub fn with_absolute_path(mut self) -> Self {
self.path_is_relative = false;
self
}
}
#[derive(Debug)]
pub struct WriteResult {
pub snapshot_id: i64,
pub table_id: i64,
pub schema_id: i64,
pub files_written: usize,
pub records_written: i64,
}
#[derive(Debug)]
pub struct WriteSetupResult {
pub snapshot_id: i64,
pub schema_id: i64,
pub table_id: i64,
pub column_ids: Vec<i64>,
}
pub trait MetadataWriter: Send + Sync + std::fmt::Debug {
fn create_snapshot(&self) -> Result<i64>;
fn get_or_create_schema(
&self,
name: &str,
path: Option<&str>,
snapshot_id: i64,
) -> Result<(i64, bool)>;
fn get_or_create_table(
&self,
schema_id: i64,
name: &str,
path: Option<&str>,
snapshot_id: i64,
) -> Result<(i64, bool)>;
fn set_columns(
&self,
table_id: i64,
columns: &[ColumnDef],
snapshot_id: i64,
) -> Result<Vec<i64>>;
fn register_data_file(
&self,
table_id: i64,
snapshot_id: i64,
file: &DataFileInfo,
) -> Result<i64>;
fn end_table_files(&self, table_id: i64, snapshot_id: i64) -> Result<u64>;
fn get_data_path(&self) -> Result<String>;
fn set_data_path(&self, path: &str) -> Result<()>;
fn initialize_schema(&self) -> Result<()>;
fn begin_write_transaction(
&self,
schema_name: &str,
table_name: &str,
columns: &[ColumnDef],
mode: WriteMode,
) -> Result<WriteSetupResult>;
}
#[cfg(test)]
mod tests {
use super::*;
use crate::DuckLakeError;
#[test]
fn test_column_def_new() {
let col = ColumnDef::new("test_col", "int32", true).unwrap();
assert_eq!(col.name, "test_col");
assert_eq!(col.ducklake_type, "int32");
assert!(col.is_nullable);
}
#[test]
fn test_column_def_new_valid_types() {
assert!(ColumnDef::new("a", "int32", true).is_ok());
assert!(ColumnDef::new("b", "varchar", false).is_ok());
assert!(ColumnDef::new("c", "boolean", true).is_ok());
assert!(ColumnDef::new("d", "float64", true).is_ok());
assert!(ColumnDef::new("e", "decimal(10,2)", true).is_ok());
assert!(ColumnDef::new("f", "timestamp", true).is_ok());
assert!(ColumnDef::new("g", "date", true).is_ok());
assert!(ColumnDef::new("h", "bigint", true).is_ok());
assert!(ColumnDef::new("i", "text", true).is_ok());
}
#[test]
fn test_column_def_new_invalid_type_rejected() {
let result = ColumnDef::new("col", "not_a_type", true);
assert!(result.is_err());
match result {
Err(DuckLakeError::UnsupportedType(msg)) => {
assert_eq!(msg, "not_a_type");
},
other => panic!("Expected UnsupportedType error, got {:?}", other),
}
}
#[test]
fn test_column_def_new_empty_type_rejected() {
let result = ColumnDef::new("col", "", true);
assert!(result.is_err());
match result {
Err(DuckLakeError::UnsupportedType(_)) => {},
other => panic!("Expected UnsupportedType error, got {:?}", other),
}
}
#[test]
fn test_column_def_from_arrow() {
let col = ColumnDef::from_arrow("id", &DataType::Int64, false).unwrap();
assert_eq!(col.name, "id");
assert_eq!(col.ducklake_type, "int64");
assert!(!col.is_nullable);
}
#[test]
fn test_data_file_info_new() {
let file = DataFileInfo::new("test.parquet", 1024, 100);
assert_eq!(file.path, "test.parquet");
assert!(file.path_is_relative);
assert_eq!(file.file_size_bytes, 1024);
assert_eq!(file.record_count, 100);
assert!(file.footer_size.is_none());
}
#[test]
fn test_data_file_info_with_footer_size() {
let file = DataFileInfo::new("test.parquet", 1024, 100).with_footer_size(256);
assert_eq!(file.footer_size, Some(256));
}
#[test]
fn test_data_file_info_with_absolute_path() {
let file = DataFileInfo::new("/absolute/path.parquet", 1024, 100).with_absolute_path();
assert!(!file.path_is_relative);
}
#[test]
fn test_column_def_empty_name_rejected() {
let result = ColumnDef::new("", "int32", true);
assert!(result.is_err());
match result {
Err(DuckLakeError::InvalidConfig(msg)) => {
assert!(msg.contains("empty"), "Expected 'empty' in: {msg}");
},
other => panic!("Expected InvalidConfig, got {:?}", other),
}
}
#[test]
fn test_column_def_control_char_name_rejected() {
let result = ColumnDef::new("col\0name", "int32", true);
assert!(result.is_err());
match result {
Err(DuckLakeError::InvalidConfig(msg)) => {
assert!(
msg.contains("control character"),
"Expected 'control character' in: {msg}"
);
},
other => panic!("Expected InvalidConfig, got {:?}", other),
}
}
#[test]
fn test_column_def_from_arrow_empty_name_rejected() {
let result = ColumnDef::from_arrow("", &DataType::Int64, false);
assert!(result.is_err());
match result {
Err(DuckLakeError::InvalidConfig(msg)) => {
assert!(msg.contains("empty"), "Expected 'empty' in: {msg}");
},
other => panic!("Expected InvalidConfig, got {:?}", other),
}
}
#[test]
fn test_column_def_from_arrow_control_char_rejected() {
let result = ColumnDef::from_arrow("col\nnewline", &DataType::Int64, false);
assert!(result.is_err());
match result {
Err(DuckLakeError::InvalidConfig(msg)) => {
assert!(
msg.contains("control character"),
"Expected 'control character' in: {msg}"
);
},
other => panic!("Expected InvalidConfig, got {:?}", other),
}
}
#[test]
fn test_validate_name_valid() {
assert!(validate_name("users", "Table").is_ok());
assert!(validate_name("my_column", "Column").is_ok());
assert!(validate_name("Schema123", "Schema").is_ok());
assert!(validate_name("a", "Column").is_ok());
}
#[test]
fn test_validate_name_empty() {
let result = validate_name("", "Table");
assert!(result.is_err());
let result = validate_name(" ", "Table");
assert!(result.is_err());
}
#[test]
fn test_validate_name_control_chars() {
assert!(validate_name("col\0", "Column").is_err());
assert!(validate_name("col\n", "Column").is_err());
assert!(validate_name("col\t", "Column").is_err());
assert!(validate_name("col\x7F", "Column").is_err());
}
#[test]
fn test_validate_name_length_limit() {
let at_limit = "a".repeat(MAX_NAME_LENGTH);
assert!(validate_name(&at_limit, "Table").is_ok());
let over_limit = "a".repeat(MAX_NAME_LENGTH + 1);
assert!(validate_name(&over_limit, "Table").is_err());
}
#[test]
fn test_column_def_long_name_rejected() {
let long_name = "x".repeat(MAX_NAME_LENGTH + 1);
let result = ColumnDef::new(long_name, "int32", true);
assert!(result.is_err());
match result {
Err(DuckLakeError::InvalidConfig(msg)) => {
assert!(
msg.contains("exceeds maximum length"),
"Expected 'exceeds maximum length' in: {msg}"
);
},
other => panic!("Expected InvalidConfig, got {:?}", other),
}
}
#[test]
fn test_data_file_info_zero_record_count() {
let file = DataFileInfo::new("empty.parquet", 0, 0);
assert_eq!(file.record_count, 0);
}
#[test]
#[should_panic(expected = "record_count must be non-negative")]
fn test_data_file_info_negative_record_count_panics() {
DataFileInfo::new("test.parquet", 1024, -1);
}
}