use crate::inlined_filter::{InlinedDataScan, InlinedFilter};
use crate::types::{arrow_to_ducklake_type, ducklake_to_arrow_type};
use crate::{DuckLakeError, Result};
use arrow::datatypes::{DataType, Field, SchemaRef};
use arrow::record_batch::RecordBatch;
use datafusion::common::ScalarValue;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
pub const SQL_GET_LATEST_SNAPSHOT: &str =
"SELECT COALESCE(MAX(snapshot_id), 0) FROM ducklake_snapshot";
pub const SQL_LIST_SNAPSHOTS: &str = "SELECT snapshot_id, CAST(snapshot_time AS VARCHAR) as timestamp FROM ducklake_snapshot ORDER BY snapshot_id";
pub const SQL_LIST_SCHEMAS: &str =
"SELECT schema_id, schema_name, path, path_is_relative FROM ducklake_schema
WHERE ? >= begin_snapshot AND (? < end_snapshot OR end_snapshot IS NULL)";
pub const SQL_LIST_TABLES: &str =
"SELECT table_id, table_name, path, path_is_relative FROM ducklake_table
WHERE schema_id = ?
AND ? >= begin_snapshot
AND (? < end_snapshot OR end_snapshot IS NULL)";
pub const SQL_LIST_VIEWS: &str =
"SELECT view_id, schema_id, begin_snapshot, view_name, dialect, sql, column_aliases FROM ducklake_view
WHERE schema_id = ?
AND ? >= begin_snapshot
AND (? < end_snapshot OR end_snapshot IS NULL)";
pub const SQL_GET_VIEW_BY_NAME: &str =
"SELECT view_id, schema_id, begin_snapshot, view_name, dialect, sql, column_aliases FROM ducklake_view
WHERE schema_id = ?
AND view_name = ?
AND ? >= begin_snapshot
AND (? < end_snapshot OR end_snapshot IS NULL)";
pub const SQL_GET_TABLE_COLUMNS: &str =
"SELECT column_id, column_name, column_type, nulls_allowed, parent_column,
initial_default, default_value, default_value_type, default_value_dialect
FROM ducklake_column
WHERE table_id = ?
AND ? >= begin_snapshot
AND (? < end_snapshot OR end_snapshot IS NULL)
ORDER BY column_order";
#[cfg(any(feature = "metadata-duckdb", feature = "metadata-sqlite"))]
pub(crate) const SQL_FILE_SCHEMA_VERSION: &str = "(SELECT sv.schema_version
FROM ducklake_schema_versions sv
WHERE sv.table_id = data.table_id
AND sv.begin_snapshot <= data.begin_snapshot
ORDER BY sv.begin_snapshot DESC
LIMIT 1)";
pub const SQL_GET_DATA_FILES: &str = "
SELECT
data.data_file_id,
data.path AS data_file_path,
data.path_is_relative AS data_path_is_relative,
data.file_size_bytes AS data_file_size,
data.footer_size AS data_footer_size,
data.encryption_key AS data_encryption_key,
data.row_id_start AS data_row_id_start,
data.record_count AS data_record_count,
del.delete_file_id,
del.path AS delete_file_path,
del.path_is_relative AS delete_path_is_relative,
del.file_size_bytes AS delete_file_size,
del.footer_size AS delete_footer_size,
del.encryption_key AS delete_encryption_key,
del.delete_count,
data.mapping_id
FROM ducklake_data_file AS data
LEFT JOIN ducklake_delete_file AS del
ON data.data_file_id = del.data_file_id
AND del.table_id = ?
AND ? >= del.begin_snapshot
AND (? < del.end_snapshot OR del.end_snapshot IS NULL)
WHERE data.table_id = ?
AND ? >= data.begin_snapshot
AND (? < data.end_snapshot OR data.end_snapshot IS NULL)";
pub const SQL_GET_NAME_MAPPING: &str = "
SELECT mapping.mapping_id, mapping.table_id, mapping.type,
name.column_id, name.source_name, name.target_field_id,
name.parent_column, name.is_partition
FROM ducklake_column_mapping AS mapping
JOIN ducklake_name_mapping AS name
ON name.mapping_id = mapping.mapping_id
WHERE mapping.mapping_id = ?
ORDER BY name.parent_column NULLS FIRST, name.column_id";
pub const SQL_GET_PARTITION_SPEC: &str = "
SELECT pi.partition_id, pc.partition_key_index, pc.column_id, pc.transform
FROM ducklake_partition_info AS pi
JOIN ducklake_partition_column AS pc
ON pc.partition_id = pi.partition_id AND pc.table_id = pi.table_id
WHERE pi.table_id = ?
AND ? >= pi.begin_snapshot
AND (? < pi.end_snapshot OR pi.end_snapshot IS NULL)
ORDER BY pc.partition_key_index";
pub const SQL_GET_SORT_SPEC: &str = "
SELECT si.sort_id, se.sort_key_index, se.expression, se.dialect,
se.sort_direction, se.null_order
FROM ducklake_sort_info AS si
JOIN ducklake_sort_expression AS se
ON se.sort_id = si.sort_id AND se.table_id = si.table_id
WHERE si.table_id = ?
AND ? >= si.begin_snapshot
AND (? < si.end_snapshot OR si.end_snapshot IS NULL)
ORDER BY se.sort_key_index";
pub const SQL_GET_FILE_PARTITION_VALUES: &str = "
SELECT data_file_id, partition_key_index, partition_value
FROM ducklake_file_partition_value
WHERE table_id = ?
AND data_file_id > ?
AND data_file_id <= ?";
pub const SQL_GET_TABLE_STATS: &str =
"SELECT record_count, file_size_bytes FROM ducklake_table_stats WHERE table_id = ?";
pub const SQL_GET_TABLE_COLUMN_STATS: &str = "
SELECT column_id, contains_null, min_value, max_value, contains_nan
FROM ducklake_table_column_stats
WHERE table_id = ?";
pub const SQL_GET_FILE_COLUMN_STATS: &str = "
SELECT
stats.data_file_id,
stats.column_id,
stats.column_size_bytes,
stats.value_count,
stats.null_count,
stats.min_value,
stats.max_value,
stats.contains_nan
FROM ducklake_file_column_stats AS stats
INNER JOIN ducklake_data_file AS data
ON data.data_file_id = stats.data_file_id
AND data.table_id = stats.table_id
WHERE stats.table_id = ?
AND ? >= data.begin_snapshot
AND (? < data.end_snapshot OR data.end_snapshot IS NULL)";
pub const SQL_GET_DATA_PATH: &str =
"SELECT value FROM ducklake_metadata WHERE key = 'data_path' AND scope IS NULL";
pub const SQL_GET_SCHEMA_BY_NAME: &str =
"SELECT schema_id, schema_name, path, path_is_relative FROM ducklake_schema
WHERE schema_name = ?
AND ? >= begin_snapshot
AND (? < end_snapshot OR end_snapshot IS NULL)";
pub const SQL_GET_TABLE_BY_NAME: &str =
"SELECT table_id, table_name, path, path_is_relative FROM ducklake_table
WHERE schema_id = ?
AND table_name = ?
AND ? >= begin_snapshot
AND (? < end_snapshot OR end_snapshot IS NULL)";
pub const SQL_TABLE_EXISTS: &str = "SELECT EXISTS(
SELECT 1 FROM ducklake_table
WHERE schema_id = ?
AND table_name = ?
AND ? >= begin_snapshot
AND (? < end_snapshot OR end_snapshot IS NULL)
)";
pub const SQL_GET_DATA_FILES_ADDED_BETWEEN_SNAPSHOTS: &str = "
SELECT
data.begin_snapshot,
data.path,
data.path_is_relative,
data.file_size_bytes,
data.footer_size,
data.encryption_key,
data.row_id_start,
data.partial_max,
data.mapping_id
FROM ducklake_data_file AS data
WHERE data.table_id = $1
AND data.begin_snapshot <= $3
AND (data.begin_snapshot >= $2
OR (data.partial_max IS NOT NULL AND data.partial_max >= $2))
ORDER BY data.begin_snapshot";
pub const SQL_GET_DELETE_FILES_ADDED_BETWEEN_SNAPSHOTS: &str = "
WITH params AS (
SELECT
? AS table_identifier,
? AS start_snapshot,
? AS finish_snapshot
),
current_delete AS (
SELECT
df.data_file_id,
df.begin_snapshot,
df.path,
df.path_is_relative,
df.file_size_bytes,
df.footer_size,
df.encryption_key
FROM ducklake_delete_file df
CROSS JOIN params p
WHERE df.table_id = p.table_identifier
AND df.begin_snapshot <= p.finish_snapshot
AND (df.begin_snapshot >= p.start_snapshot
OR (df.partial_max IS NOT NULL AND df.partial_max >= p.start_snapshot))
),
all_deletes AS (
SELECT
df.data_file_id,
df.begin_snapshot,
df.path,
df.path_is_relative,
df.file_size_bytes,
df.footer_size,
df.encryption_key
FROM ducklake_delete_file df
CROSS JOIN params p
WHERE df.table_id = p.table_identifier
)
SELECT
data.path,
data.path_is_relative,
data.file_size_bytes,
data.footer_size,
data.row_id_start,
data.record_count,
data.mapping_id,
cd.path AS current_delete_path,
cd.path_is_relative AS current_delete_path_is_relative,
cd.file_size_bytes AS current_delete_file_size_bytes,
cd.footer_size AS current_delete_footer_size,
pd.path AS previous_delete_path,
pd.path_is_relative AS previous_delete_path_is_relative,
pd.file_size_bytes AS previous_delete_file_size_bytes,
pd.footer_size AS previous_delete_footer_size,
cd.begin_snapshot
FROM current_delete cd
JOIN ducklake_data_file data
ON data.data_file_id = cd.data_file_id
LEFT JOIN LATERAL (
SELECT path, path_is_relative, file_size_bytes, footer_size
FROM all_deletes ad
WHERE ad.data_file_id = cd.data_file_id
AND ad.begin_snapshot < cd.begin_snapshot
ORDER BY ad.begin_snapshot DESC
LIMIT 1
) pd ON true
CROSS JOIN params p
WHERE data.table_id = p.table_identifier
UNION ALL
SELECT
data.path,
data.path_is_relative,
data.file_size_bytes,
data.footer_size,
data.row_id_start,
data.record_count,
data.mapping_id,
NULL,
NULL,
NULL,
NULL,
pd.path,
pd.path_is_relative,
pd.file_size_bytes,
pd.footer_size,
data.end_snapshot
FROM ducklake_data_file data
LEFT JOIN LATERAL (
SELECT path, path_is_relative, file_size_bytes, footer_size
FROM all_deletes ad
WHERE ad.data_file_id = data.data_file_id
AND ad.begin_snapshot < data.end_snapshot
ORDER BY ad.begin_snapshot DESC
LIMIT 1
) pd ON true
CROSS JOIN params p
WHERE data.table_id = p.table_identifier
AND data.end_snapshot >= p.start_snapshot
AND data.end_snapshot <= p.finish_snapshot;
";
pub const SQL_LIST_ALL_TABLES: &str = "
SELECT
s.schema_name,
t.table_id,
t.table_name,
t.path,
t.path_is_relative
FROM ducklake_schema s
JOIN ducklake_table t ON s.schema_id = t.schema_id
WHERE ? >= s.begin_snapshot
AND (? < s.end_snapshot OR s.end_snapshot IS NULL)
AND ? >= t.begin_snapshot
AND (? < t.end_snapshot OR t.end_snapshot IS NULL)
ORDER BY s.schema_name, t.table_name";
pub const SQL_LIST_ALL_VIEWS: &str = "
SELECT s.schema_name, v.view_id, v.schema_id, v.begin_snapshot, v.view_name, v.dialect, v.sql,
v.column_aliases
FROM ducklake_schema s
JOIN ducklake_view v ON s.schema_id = v.schema_id
WHERE ? >= s.begin_snapshot
AND (? < s.end_snapshot OR s.end_snapshot IS NULL)
AND ? >= v.begin_snapshot
AND (? < v.end_snapshot OR v.end_snapshot IS NULL)
ORDER BY s.schema_name, v.view_name";
pub const SQL_LIST_ALL_COLUMNS: &str = "
SELECT
s.schema_name,
t.table_name,
c.column_id,
c.column_name,
c.column_type,
c.nulls_allowed,
c.parent_column,
c.initial_default,
c.default_value,
c.default_value_type,
c.default_value_dialect
FROM ducklake_schema s
JOIN ducklake_table t ON s.schema_id = t.schema_id
JOIN ducklake_column c ON t.table_id = c.table_id
WHERE ? >= s.begin_snapshot
AND (? < s.end_snapshot OR s.end_snapshot IS NULL)
AND ? >= t.begin_snapshot
AND (? < t.end_snapshot OR t.end_snapshot IS NULL)
AND ? >= c.begin_snapshot
AND (? < c.end_snapshot OR c.end_snapshot IS NULL)
ORDER BY s.schema_name, t.table_name, c.column_order";
pub const SQL_LIST_ALL_FILES: &str = "
SELECT
s.schema_name,
t.table_name,
data.data_file_id,
data.path AS data_file_path,
data.path_is_relative AS data_path_is_relative,
data.file_size_bytes AS data_file_size,
data.footer_size AS data_footer_size,
data.encryption_key AS data_encryption_key,
del.delete_file_id,
del.path AS delete_file_path,
del.path_is_relative AS delete_path_is_relative,
del.file_size_bytes AS delete_file_size,
del.footer_size AS delete_footer_size,
del.encryption_key AS delete_encryption_key,
del.delete_count
FROM ducklake_schema s
JOIN ducklake_table t ON s.schema_id = t.schema_id
JOIN ducklake_data_file data ON t.table_id = data.table_id
LEFT JOIN ducklake_delete_file del
ON data.data_file_id = del.data_file_id
AND del.table_id = t.table_id
AND ? >= del.begin_snapshot
AND (? < del.end_snapshot OR del.end_snapshot IS NULL)
WHERE ? >= s.begin_snapshot
AND (? < s.end_snapshot OR s.end_snapshot IS NULL)
AND ? >= t.begin_snapshot
AND (? < t.end_snapshot OR t.end_snapshot IS NULL)
AND ? >= data.begin_snapshot
AND (? < data.end_snapshot OR data.end_snapshot IS NULL)
ORDER BY s.schema_name, t.table_name, data.path";
#[derive(Debug, Clone)]
pub struct SnapshotMetadata {
pub snapshot_id: i64,
pub timestamp: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SnapshotChangeMetadata {
pub snapshot_id: i64,
pub timestamp: Option<String>,
pub changes_made: Option<String>,
pub author: Option<String>,
pub commit_message: Option<String>,
pub commit_extra_info: Option<String>,
}
pub(crate) fn parse_snapshot_timestamp(raw: &str) -> Option<chrono::NaiveDateTime> {
let mut timestamp = raw.trim();
for suffix in ["Z", " UTC", "+00:00", "+00"] {
if let Some(stripped) = timestamp.strip_suffix(suffix) {
timestamp = stripped.trim();
break;
}
}
for format in ["%Y-%m-%d %H:%M:%S%.f", "%Y-%m-%dT%H:%M:%S%.f"] {
if let Ok(parsed) = chrono::NaiveDateTime::parse_from_str(timestamp, format) {
return Some(parsed);
}
}
chrono::NaiveDate::parse_from_str(timestamp, "%Y-%m-%d")
.ok()
.and_then(|date| date.and_hms_opt(0, 0, 0))
}
pub(crate) fn resolve_snapshot_at_or_before(
provider: &dyn MetadataProvider,
timestamp: chrono::NaiveDateTime,
) -> Result<i64> {
resolve_snapshot_at(provider, timestamp, false)
}
pub(crate) fn resolve_snapshot_at_or_after(
provider: &dyn MetadataProvider,
timestamp: chrono::NaiveDateTime,
) -> Result<i64> {
resolve_snapshot_at(provider, timestamp, true)
}
fn resolve_snapshot_at(
provider: &dyn MetadataProvider,
timestamp: chrono::NaiveDateTime,
at_or_after: bool,
) -> Result<i64> {
let mut best: Option<(chrono::NaiveDateTime, i64)> = None;
for snapshot in provider.list_snapshots()? {
let Some(candidate_time) = snapshot
.timestamp
.as_deref()
.and_then(parse_snapshot_timestamp)
else {
continue;
};
if (at_or_after && candidate_time < timestamp)
|| (!at_or_after && candidate_time > timestamp)
{
continue;
}
let replace = match best {
None => true,
Some((best_time, best_id)) if at_or_after => {
candidate_time < best_time
|| (candidate_time == best_time && snapshot.snapshot_id < best_id)
},
Some((best_time, best_id)) => {
candidate_time > best_time
|| (candidate_time == best_time && snapshot.snapshot_id > best_id)
},
};
if replace {
best = Some((candidate_time, snapshot.snapshot_id));
}
}
best.map(|(_, snapshot_id)| snapshot_id).ok_or_else(|| {
crate::error::DuckLakeError::InvalidSnapshot(format!(
"No snapshot found {} timestamp {timestamp}",
if at_or_after {
"at or after"
} else {
"at or before"
}
))
})
}
pub(crate) fn require_snapshot(provider: &dyn MetadataProvider, snapshot_id: i64) -> Result<i64> {
if provider
.list_snapshots()?
.iter()
.any(|snapshot| snapshot.snapshot_id == snapshot_id)
{
Ok(snapshot_id)
} else {
Err(crate::error::DuckLakeError::InvalidSnapshot(format!(
"Snapshot {snapshot_id} does not exist"
)))
}
}
#[derive(Debug, Clone)]
pub struct SchemaMetadata {
pub schema_id: i64,
pub schema_name: String,
pub path: String,
pub path_is_relative: bool,
}
#[derive(Debug, Clone)]
pub struct TableMetadata {
pub table_id: i64,
pub table_name: String,
pub path: String,
pub path_is_relative: bool,
}
#[derive(Debug, Clone)]
pub struct TableWithSchema {
pub schema_name: String,
pub table: TableMetadata,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ViewMetadata {
pub view_id: i64,
pub schema_id: i64,
pub begin_snapshot: i64,
pub view_name: String,
pub dialect: String,
pub sql: String,
pub column_aliases: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ViewWithSchema {
pub schema_name: String,
pub view: ViewMetadata,
}
#[derive(Debug, Clone)]
pub struct ColumnWithTable {
pub schema_name: String,
pub table_name: String,
pub column: DuckLakeTableColumn,
}
#[derive(Debug, Clone)]
pub struct FileWithTable {
pub schema_name: String,
pub table_name: String,
pub file: DuckLakeTableFile,
}
#[derive(Debug, Clone)]
pub struct DuckLakeTableColumn {
pub column_id: i64,
pub column_name: String,
pub column_type: String,
pub is_nullable: bool,
pub(crate) data_type: Option<DataType>,
pub(crate) nested_column_ids: Vec<i64>,
pub initial_default: Option<String>,
pub default_value: Option<String>,
pub default_value_type: Option<String>,
pub default_value_dialect: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DuckLakeInlinedDelete {
pub data_file_id: i64,
pub row_id: i64,
}
pub(crate) fn is_inlined_data_table(name: &str) -> bool {
name.starts_with("ducklake_inlined_data_")
&& name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
}
pub(crate) const INLINED_DATA_REMEDIATION: &str =
"flush inlined data to Parquet (or disable data inlining at write time)";
pub fn inlined_delete_table_name(table_id: i64) -> Result<String> {
if table_id < 0 {
return Err(crate::DuckLakeError::InvalidConfig(format!(
"DuckLake table id must be non-negative, was {table_id}"
)));
}
Ok(format!("ducklake_inlined_delete_{table_id}"))
}
#[derive(Debug, Clone, Copy)]
pub(crate) enum InlinedDataBackend {
#[cfg_attr(not(feature = "metadata-postgres"), allow(dead_code))]
Postgres,
#[cfg_attr(not(feature = "metadata-mysql"), allow(dead_code))]
MySql,
}
pub(crate) fn inlined_text_projection(
backend: InlinedDataBackend,
column: &DuckLakeTableColumn,
data_type: &arrow::datatypes::DataType,
ident: &str,
) -> String {
use arrow::datatypes::DataType;
match backend {
InlinedDataBackend::Postgres => match data_type {
DataType::FixedSizeBinary(_)
if column.column_type.trim().eq_ignore_ascii_case("uuid") =>
{
format!("CAST({ident} AS TEXT)")
},
DataType::Binary
| DataType::LargeBinary
| DataType::BinaryView
| DataType::FixedSizeBinary(_) => format!("encode({ident}, 'hex')"),
DataType::Utf8 | DataType::LargeUtf8 | DataType::Utf8View
if matches!(
column.column_type.trim().to_ascii_lowercase().as_str(),
"varchar" | "text" | "string" | "json"
) =>
{
format!("convert_from({ident}, 'UTF8')")
},
_ => format!("CAST({ident} AS TEXT)"),
},
InlinedDataBackend::MySql => match data_type {
DataType::FixedSizeBinary(_)
if column.column_type.trim().eq_ignore_ascii_case("uuid") =>
{
format!("CAST({ident} AS CHAR CHARACTER SET utf8mb4)")
},
DataType::Binary
| DataType::LargeBinary
| DataType::BinaryView
| DataType::FixedSizeBinary(_) => format!("HEX({ident})"),
_ => format!("CAST({ident} AS CHAR CHARACTER SET utf8mb4)"),
},
}
}
pub(crate) fn build_inlined_batch(
schema: SchemaRef,
columns: &[DuckLakeTableColumn],
rows: &[Vec<ScalarValue>],
) -> Result<RecordBatch> {
if rows.iter().any(|row| row.len() != columns.len()) {
return Err(crate::DuckLakeError::Internal(
"inlined data row does not match the catalog column count".to_string(),
));
}
let arrays = (0..columns.len())
.map(|index| ScalarValue::iter_to_array(rows.iter().map(|row| row[index].clone())))
.collect::<std::result::Result<Vec<_>, _>>()?;
Ok(RecordBatch::try_new(schema, arrays)?)
}
pub(crate) fn snapshot_change_tokens(changes: &str) -> impl Iterator<Item = &str> {
let mut quoted = false;
changes.split(move |character| {
if character == '"' {
quoted = !quoted;
}
character == ',' && !quoted
})
}
pub(crate) fn reject_inlined_changes(
provider: &dyn MetadataProvider,
table_id: i64,
start_snapshot: i64,
end_snapshot: i64,
kinds: &[&str],
) -> Result<()> {
let changes = match provider.list_snapshot_changes() {
Ok(changes) => changes,
Err(DuckLakeError::Unsupported(_)) => return Ok(()),
Err(e) => return Err(e),
};
let tokens = kinds
.iter()
.map(|kind| format!("{kind}:{table_id}"))
.collect::<Vec<_>>();
if changes.iter().any(|change| {
change.snapshot_id >= start_snapshot
&& change.snapshot_id <= end_snapshot
&& change.changes_made.as_deref().is_some_and(|changes| {
snapshot_change_tokens(changes)
.any(|token| tokens.iter().any(|expected| token == expected))
})
}) {
return Err(DuckLakeError::Unsupported(
"change feeds over inlined rows are not supported; use Parquet writes for change-feed tables".to_string(),
));
}
Ok(())
}
#[cfg(test)]
pub(crate) fn parse_inlined_rows(
schema: SchemaRef,
columns: &[DuckLakeTableColumn],
rows: Vec<Vec<Option<String>>>,
) -> Result<RecordBatch> {
parse_inlined_rows_with_present(schema, columns, rows, None)
}
pub(crate) fn parse_inlined_rows_with_present(
schema: SchemaRef,
columns: &[DuckLakeTableColumn],
rows: Vec<Vec<Option<String>>>,
present: Option<&HashSet<String>>,
) -> Result<RecordBatch> {
let rows = rows
.into_iter()
.map(|row| {
if row.len() != columns.len() {
return Err(crate::DuckLakeError::Internal(
"inlined data row does not match the catalog column count".to_string(),
));
}
row.into_iter()
.enumerate()
.map(|(index, value)| {
let field = schema.field(index);
if present.is_some_and(|present| {
!present.contains(columns[index].column_name.as_str())
}) {
return inlined_missing_scalar(&columns[index], field.data_type());
}
match value {
Some(value) => crate::types::parse_ducklake_scalar(
&value,
field.data_type(),
)
.ok_or_else(|| {
crate::DuckLakeError::Unsupported(format!(
"inlined data for column '{}' cannot decode value '{}' as {}; \
{INLINED_DATA_REMEDIATION}",
columns[index].column_name,
value,
field.data_type()
))
}),
None => Ok(ScalarValue::try_from(field.data_type())?),
}
})
.collect::<Result<Vec<_>>>()
})
.collect::<Result<Vec<_>>>()?;
build_inlined_batch(schema, columns, &rows)
}
pub(crate) fn inlined_missing_scalar(
column: &DuckLakeTableColumn,
data_type: &DataType,
) -> Result<ScalarValue> {
let Some(value) = column
.initial_default
.as_deref()
.filter(|value| !value.eq_ignore_ascii_case("NULL"))
else {
return Ok(ScalarValue::try_from(data_type)?);
};
crate::types::parse_ducklake_scalar(value, data_type).ok_or_else(|| {
crate::DuckLakeError::InvalidConfig(format!(
"Cannot decode initial_default '{value}' for inlined column '{}' as {data_type}",
column.column_name,
))
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DuckLakeTableField {
pub column_id: i64,
pub column_name: String,
pub column_type: String,
pub is_nullable: bool,
pub parent_column: Option<i64>,
}
impl DuckLakeTableField {
pub fn top_level(column: DuckLakeTableColumn) -> Self {
Self {
column_id: column.column_id,
column_name: column.column_name,
column_type: column.column_type,
is_nullable: column.is_nullable,
parent_column: None,
}
}
pub fn column(&self) -> DuckLakeTableColumn {
DuckLakeTableColumn::new(
self.column_id,
self.column_name.clone(),
self.column_type.clone(),
self.is_nullable,
)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DuckLakeNameMappingEntry {
pub column_id: i64,
pub source_name: String,
pub target_field_id: i64,
pub parent_column: Option<i64>,
pub is_partition: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DuckLakeNameMapping {
pub mapping_id: i64,
pub table_id: i64,
pub mapping_type: String,
pub entries: Vec<DuckLakeNameMappingEntry>,
}
#[derive(Debug, Clone)]
pub struct DuckLakeInlinedData {
pub table_name: String,
pub row_ids: Vec<i64>,
pub begin_snapshots: Vec<i64>,
pub batch: arrow::record_batch::RecordBatch,
}
impl DuckLakeTableColumn {
pub fn new(
column_id: i64,
column_name: String,
column_type: String,
is_nullable: bool,
) -> Self {
Self {
column_id,
column_name,
column_type,
is_nullable,
data_type: None,
nested_column_ids: Vec::new(),
initial_default: None,
default_value: None,
default_value_type: None,
default_value_dialect: None,
}
}
pub(crate) fn data_type(&self) -> Result<DataType> {
match &self.data_type {
Some(data_type) => Ok(data_type.clone()),
None => ducklake_to_arrow_type(&self.column_type),
}
}
pub(crate) fn with_defaults(
mut self,
initial_default: Option<String>,
default_value: Option<String>,
default_value_type: Option<String>,
default_value_dialect: Option<String>,
) -> Self {
self.initial_default = initial_default;
self.default_value = default_value.filter(|value| value != "NULL");
self.default_value_type = default_value_type;
self.default_value_dialect = default_value_dialect;
self
}
}
pub fn reconstruct_columns(
rows: Vec<(DuckLakeTableColumn, Option<i64>)>,
) -> Result<Vec<DuckLakeTableColumn>> {
let id_to_index: HashMap<i64, usize> = rows
.iter()
.enumerate()
.map(|(index, (column, _))| (column.column_id, index))
.collect();
if id_to_index.len() != rows.len() {
return Err(crate::DuckLakeError::InvalidConfig(
"DuckLake column metadata contains duplicate column ids".into(),
));
}
let mut children: HashMap<i64, Vec<usize>> = HashMap::new();
for (index, (_, parent_id)) in rows.iter().enumerate() {
if let Some(parent_id) = parent_id {
if !id_to_index.contains_key(parent_id) {
return Err(crate::DuckLakeError::InvalidConfig(format!(
"Nested column {} references missing parent column {parent_id}",
rows[index].0.column_id
)));
}
children.entry(*parent_id).or_default().push(index);
}
}
fn build_type(
index: usize,
rows: &[(DuckLakeTableColumn, Option<i64>)],
children: &HashMap<i64, Vec<usize>>,
visiting: &mut HashSet<i64>,
) -> Result<DataType> {
let column = &rows[index].0;
if visiting.len() >= crate::types::MAX_NESTED_TYPE_DEPTH {
return Err(crate::DuckLakeError::InvalidConfig(format!(
"Nested column metadata exceeds maximum depth {}",
crate::types::MAX_NESTED_TYPE_DEPTH
)));
}
if !visiting.insert(column.column_id) {
return Err(crate::DuckLakeError::InvalidConfig(format!(
"Nested column cycle includes column {}",
column.column_id
)));
}
let child_indices = children
.get(&column.column_id)
.map(Vec::as_slice)
.unwrap_or_default();
let data_type = match column.column_type.to_ascii_lowercase().as_str() {
"list" => {
let [child_index] = child_indices else {
return Err(crate::DuckLakeError::InvalidConfig(format!(
"List column '{}' must have exactly one child",
column.column_name
)));
};
let child = &rows[*child_index].0;
DataType::List(Arc::new(Field::new(
"item",
build_type(*child_index, rows, children, visiting)?,
child.is_nullable,
)))
},
"struct" => {
let fields = child_indices
.iter()
.map(|child_index| {
let child = &rows[*child_index].0;
Ok(Arc::new(Field::new(
&child.column_name,
build_type(*child_index, rows, children, visiting)?,
child.is_nullable,
)))
})
.collect::<Result<Vec<_>>>()?;
DataType::Struct(fields.into())
},
"map" => {
let [key_index, value_index] = child_indices else {
return Err(crate::DuckLakeError::InvalidConfig(format!(
"Map column '{}' must have key and value children",
column.column_name
)));
};
let key = &rows[*key_index].0;
let value = &rows[*value_index].0;
if key.column_name != "key" || value.column_name != "value" {
return Err(crate::DuckLakeError::InvalidConfig(format!(
"Map column '{}' children must be named key then value",
column.column_name
)));
}
let entries = DataType::Struct(
vec![
Arc::new(Field::new(
"key",
build_type(*key_index, rows, children, visiting)?,
false,
)),
Arc::new(Field::new(
"value",
build_type(*value_index, rows, children, visiting)?,
value.is_nullable,
)),
]
.into(),
);
DataType::Map(Arc::new(Field::new("entries", entries, false)), false)
},
_ if child_indices.is_empty() => ducklake_to_arrow_type(&column.column_type)?,
_ => {
return Err(crate::DuckLakeError::InvalidConfig(format!(
"Non-nested column '{}' has child columns",
column.column_name
)));
},
};
visiting.remove(&column.column_id);
Ok(data_type)
}
let mut result = Vec::new();
for (index, (column, parent_id)) in rows.iter().enumerate() {
if parent_id.is_some() {
continue;
}
let mut column = column.clone();
let data_type = build_type(index, &rows, &children, &mut HashSet::new())?;
column.column_type = reconstructed_column_type(&column.column_type, &data_type)?;
column.data_type = Some(data_type);
fn collect_ids(
column_id: i64,
rows: &[(DuckLakeTableColumn, Option<i64>)],
children: &HashMap<i64, Vec<usize>>,
ids: &mut Vec<i64>,
) {
if let Some(child_indices) = children.get(&column_id) {
for child_index in child_indices {
let child_id = rows[*child_index].0.column_id;
ids.push(child_id);
collect_ids(child_id, rows, children, ids);
}
}
}
collect_ids(
column.column_id,
&rows,
&children,
&mut column.nested_column_ids,
);
result.push(column);
}
let reconstructed_count = result
.iter()
.map(|column| 1 + column.nested_column_ids.len())
.sum::<usize>();
if reconstructed_count != rows.len() {
return Err(crate::DuckLakeError::InvalidConfig(
"DuckLake column metadata contains a parent cycle or unreachable nested column".into(),
));
}
Ok(result)
}
fn reconstructed_column_type(catalog_type: &str, data_type: &DataType) -> Result<String> {
let normalized = catalog_type.trim().to_ascii_lowercase();
let preserves_logical_name = matches!(data_type, DataType::Binary)
|| matches!(data_type, DataType::Utf8View)
&& !matches!(normalized.as_str(), "varchar" | "text" | "string");
if preserves_logical_name {
Ok(catalog_type.to_string())
} else {
arrow_to_ducklake_type(data_type)
}
}
pub fn reconstruct_columns_with_table(
rows: Vec<(ColumnWithTable, Option<i64>)>,
) -> Result<Vec<ColumnWithTable>> {
type ColumnRowsByTable = HashMap<(String, String), Vec<(DuckLakeTableColumn, Option<i64>)>>;
let mut grouped = ColumnRowsByTable::new();
let mut order = Vec::new();
for (entry, parent_id) in rows {
let key = (entry.schema_name, entry.table_name);
if !grouped.contains_key(&key) {
order.push(key.clone());
}
grouped
.entry(key)
.or_default()
.push((entry.column, parent_id));
}
let mut result = Vec::new();
for (schema_name, table_name) in order {
let columns = reconstruct_columns(
grouped
.remove(&(schema_name.clone(), table_name.clone()))
.unwrap_or_default(),
)?;
result.extend(columns.into_iter().map(|column| ColumnWithTable {
schema_name: schema_name.clone(),
table_name: table_name.clone(),
column,
}));
}
Ok(result)
}
#[derive(Debug, Clone, Default)]
#[non_exhaustive]
pub struct DuckLakeFileData {
pub path: String,
pub path_is_relative: bool,
pub encryption_key: Option<String>,
pub file_size_bytes: i64,
pub footer_size: Option<i64>,
pub mapping_id: Option<i64>,
}
impl DuckLakeFileData {
pub fn new(path: String, path_is_relative: bool, file_size_bytes: i64) -> Self {
Self {
path,
path_is_relative,
encryption_key: None,
file_size_bytes,
footer_size: None,
mapping_id: None,
}
}
}
#[derive(Debug, Clone)]
pub struct DuckLakeTableFile {
pub data_file_id: i64,
pub file: DuckLakeFileData,
pub delete_file_id: Option<i64>,
pub delete_file: Option<DuckLakeFileData>,
pub row_id_start: Option<i64>,
pub snapshot_id: Option<i64>,
pub begin_snapshot: Option<i64>,
pub schema_version: Option<i64>,
pub partial_max: Option<i64>,
pub max_row_count: Option<i64>,
pub delete_count: Option<i64>,
pub partition_id: Option<i64>,
pub partition_values: Vec<(i32, Option<String>)>,
}
#[derive(Debug, Clone, Default)]
pub struct DuckLakeStatistics {
pub table: Option<DuckLakeTableStatistics>,
pub columns: Vec<DuckLakeTableColumnStatistics>,
pub files: Vec<DuckLakeFileColumnStatistics>,
}
#[derive(Debug, Clone)]
pub struct DuckLakeTableStatistics {
pub record_count: Option<i64>,
pub file_size_bytes: Option<i64>,
}
#[derive(Debug, Clone)]
pub struct DuckLakeTableColumnStatistics {
pub column_id: i64,
pub contains_null: Option<bool>,
pub min_value: Option<String>,
pub max_value: Option<String>,
pub contains_nan: Option<bool>,
pub column_size_bytes: Option<i64>,
pub bounds_are_exact: bool,
}
#[derive(Debug, Clone)]
pub struct DuckLakeFileColumnStatistics {
pub data_file_id: i64,
pub column_id: i64,
pub column_size_bytes: Option<i64>,
pub value_count: Option<i64>,
pub null_count: Option<i64>,
pub min_value: Option<String>,
pub max_value: Option<String>,
pub contains_nan: Option<bool>,
}
#[derive(Debug, Clone)]
pub struct DuckLakeFileMetadata {
pub file: DuckLakeTableFile,
pub column_statistics: Vec<DuckLakeFileColumnStatistics>,
}
pub const FILE_METADATA_BATCH_SIZE: usize = 4_096;
impl DuckLakeTableFile {
pub fn new(file: DuckLakeFileData) -> Self {
Self {
data_file_id: 0,
file,
delete_file_id: None,
delete_file: None,
row_id_start: None,
snapshot_id: None,
begin_snapshot: None,
schema_version: None,
partial_max: None,
max_row_count: None,
delete_count: None,
partition_id: None,
partition_values: Vec::new(),
}
}
}
#[derive(Debug, Clone, Default)]
#[non_exhaustive]
pub struct DataFileChange {
pub begin_snapshot: i64,
pub path: String,
pub path_is_relative: bool,
pub file_size_bytes: i64,
pub footer_size: Option<i64>,
pub encryption_key: Option<String>,
pub mapping_id: Option<i64>,
pub row_id_start: Option<i64>,
pub partial_max: Option<i64>,
}
#[derive(Debug, Clone)]
pub struct DeleteFileChange {
pub data_file_path: String,
pub data_file_path_is_relative: bool,
pub data_file_size_bytes: i64,
pub data_file_footer_size: Option<i64>,
pub data_row_id_start: Option<i64>,
pub data_record_count: i64,
pub data_mapping_id: Option<i64>,
pub current_delete_path: Option<String>,
pub current_delete_path_is_relative: Option<bool>,
pub current_delete_file_size_bytes: Option<i64>,
pub current_delete_footer_size: Option<i64>,
pub previous_delete_path: Option<String>,
pub previous_delete_path_is_relative: Option<bool>,
pub previous_delete_file_size_bytes: Option<i64>,
pub previous_delete_footer_size: Option<i64>,
pub snapshot_id: i64,
}
#[derive(Debug)]
pub(crate) struct MetadataSetting {
pub key: String,
pub value: String,
pub scope: Option<String>,
pub scope_id: Option<i64>,
}
pub(crate) fn resolve_metadata_settings(
rows: Vec<MetadataSetting>,
schema_id: Option<i64>,
table_id: Option<i64>,
) -> Result<HashMap<String, String>> {
let mut global = HashMap::new();
let mut schema = HashMap::new();
let mut table = HashMap::new();
for row in rows {
match row.scope.as_deref() {
None => {
global.insert(row.key, row.value);
},
Some("schema") if row.scope_id.is_none() => {
return Err(crate::error::DuckLakeError::InvalidConfig(format!(
"ducklake_metadata schema setting '{}' has no scope_id",
row.key
)));
},
Some("schema") if row.scope_id == schema_id => {
schema.insert(row.key, row.value);
},
Some("table") if row.scope_id.is_none() => {
return Err(crate::error::DuckLakeError::InvalidConfig(format!(
"ducklake_metadata table setting '{}' has no scope_id",
row.key
)));
},
Some("table") if row.scope_id == table_id => {
table.insert(row.key, row.value);
},
Some("schema") | Some("table") => {},
Some(scope) => {
return Err(crate::error::DuckLakeError::InvalidConfig(format!(
"Unsupported ducklake_metadata scope '{scope}'; expected schema or table"
)));
},
}
}
global.extend(schema);
global.extend(table);
Ok(global)
}
pub trait MetadataProvider: Send + Sync + std::fmt::Debug {
fn get_current_snapshot(&self) -> Result<i64>;
fn get_data_path(&self) -> Result<String>;
fn get_metadata_settings(
&self,
_schema_id: Option<i64>,
_table_id: Option<i64>,
) -> Result<HashMap<String, String>> {
Ok(HashMap::new())
}
fn list_snapshots(&self) -> Result<Vec<SnapshotMetadata>>;
fn list_snapshot_changes(&self) -> Result<Vec<SnapshotChangeMetadata>> {
Err(DuckLakeError::Unsupported(
"snapshot change metadata is not supported by this backend".to_string(),
))
}
fn find_snapshot_by_commit_extra_info(&self, _needle: &str) -> Result<Option<i64>> {
Err(DuckLakeError::Unsupported(
"commit metadata lookup is not supported by this backend".to_string(),
))
}
fn list_schemas(&self, snapshot_id: i64) -> Result<Vec<SchemaMetadata>>;
fn list_tables(&self, schema_id: i64, snapshot_id: i64) -> Result<Vec<TableMetadata>>;
fn list_views(&self, _schema_id: i64, _snapshot_id: i64) -> Result<Vec<ViewMetadata>> {
Ok(Vec::new())
}
fn get_table_structure(
&self,
table_id: i64,
snapshot_id: i64,
) -> Result<Vec<DuckLakeTableColumn>>;
fn get_table_fields(&self, table_id: i64, snapshot_id: i64) -> Result<Vec<DuckLakeTableField>> {
self.get_table_structure(table_id, snapshot_id)
.map(|columns| {
columns
.into_iter()
.map(DuckLakeTableField::top_level)
.collect()
})
}
fn get_name_mapping(&self, mapping_id: i64) -> Result<DuckLakeNameMapping> {
Err(crate::DuckLakeError::Unsupported(format!(
"metadata provider does not support DuckLake name mapping {mapping_id}"
)))
}
fn get_table_files_for_select(
&self,
table_id: i64,
snapshot_id: i64,
) -> Result<Vec<DuckLakeTableFile>>;
fn get_partition_spec(
&self,
_table_id: i64,
_snapshot_id: i64,
) -> Result<Option<crate::partition::PartitionSpec>> {
Ok(None)
}
fn get_sort_spec(
&self,
_table_id: i64,
_snapshot_id: i64,
) -> Result<Option<crate::sort::SortSpec>> {
Ok(None)
}
fn get_table_statistics(
&self,
_table_id: i64,
_snapshot_id: i64,
) -> Result<DuckLakeStatistics> {
Ok(DuckLakeStatistics::default())
}
fn get_table_summary_statistics(
&self,
table_id: i64,
snapshot_id: i64,
) -> Result<DuckLakeStatistics> {
let mut statistics = self.get_table_statistics(table_id, snapshot_id)?;
statistics.files.clear();
Ok(statistics)
}
fn get_table_file_metadata_page(
&self,
table_id: i64,
snapshot_id: i64,
after_data_file_id: Option<i64>,
limit: usize,
) -> Result<Vec<DuckLakeFileMetadata>> {
let mut files = self.get_table_files_for_select(table_id, snapshot_id)?;
files.sort_by_key(|file| file.data_file_id);
let statistics = self.get_table_statistics(table_id, snapshot_id)?.files;
let mut by_file: HashMap<i64, Vec<DuckLakeFileColumnStatistics>> = HashMap::new();
for statistic in statistics {
by_file
.entry(statistic.data_file_id)
.or_default()
.push(statistic);
}
Ok(files
.into_iter()
.filter(|file| after_data_file_id.is_none_or(|after| file.data_file_id > after))
.take(limit)
.map(|file| DuckLakeFileMetadata {
column_statistics: by_file.remove(&file.data_file_id).unwrap_or_default(),
file,
})
.collect())
}
fn get_table_file_metadata_page_filtered(
&self,
table_id: i64,
snapshot_id: i64,
after_data_file_id: Option<i64>,
limit: usize,
filter: Option<&crate::stats_filter::StatsFilter>,
) -> Result<Vec<DuckLakeFileMetadata>> {
let _ = filter;
self.get_table_file_metadata_page(table_id, snapshot_id, after_data_file_id, limit)
}
fn get_inlined_data(
&self,
_table_id: i64,
_snapshot_id: i64,
_columns: &[DuckLakeTableColumn],
) -> Result<Vec<arrow::record_batch::RecordBatch>> {
Ok(Vec::new())
}
fn scan_inlined_data(
&self,
table_id: i64,
snapshot_id: i64,
columns: &[DuckLakeTableColumn],
_filter: Option<&InlinedFilter>,
) -> Result<InlinedDataScan> {
Ok(InlinedDataScan::from_batches(self.get_inlined_data(
table_id,
snapshot_id,
columns,
)?))
}
fn get_inlined_deletes(
&self,
_table_id: i64,
_snapshot_id: i64,
) -> Result<Vec<DuckLakeInlinedDelete>> {
Ok(Vec::new())
}
fn get_inlined_data_with_row_ids(
&self,
_table_id: i64,
_snapshot_id: i64,
_columns: &[DuckLakeTableColumn],
) -> Result<Vec<DuckLakeInlinedData>> {
Ok(Vec::new())
}
fn get_table_row_count(&self, table_id: i64, snapshot_id: i64) -> Result<u64> {
let files = self.get_table_files_for_select(table_id, snapshot_id)?;
let visible_file_ids = files
.iter()
.map(|file| file.data_file_id)
.collect::<std::collections::HashSet<_>>();
let inlined_count = self
.get_inlined_deletes(table_id, snapshot_id)?
.into_iter()
.filter(|delete| visible_file_ids.contains(&delete.data_file_id))
.map(|delete| (delete.data_file_id, delete.row_id))
.collect::<std::collections::HashSet<_>>()
.len() as i64;
let net: i64 = files
.iter()
.map(|f| f.max_row_count.unwrap_or(0) - f.delete_count.unwrap_or(0))
.sum::<i64>()
- inlined_count;
let columns = self.get_table_structure(table_id, snapshot_id)?;
let inlined_rows = self
.get_inlined_data(table_id, snapshot_id, &columns)?
.iter()
.map(|batch| batch.num_rows() as u64)
.sum::<u64>();
Ok(net.max(0) as u64 + inlined_rows)
}
fn get_schema_by_name(&self, name: &str, snapshot_id: i64) -> Result<Option<SchemaMetadata>>;
fn get_table_by_name(
&self,
schema_id: i64,
name: &str,
snapshot_id: i64,
) -> Result<Option<TableMetadata>>;
fn get_view_by_name(
&self,
_schema_id: i64,
_name: &str,
_snapshot_id: i64,
) -> Result<Option<ViewMetadata>> {
Ok(None)
}
fn table_exists(&self, schema_id: i64, name: &str, snapshot_id: i64) -> Result<bool>;
fn list_all_tables(&self, snapshot_id: i64) -> Result<Vec<TableWithSchema>>;
fn list_all_views(&self, snapshot_id: i64) -> Result<Vec<ViewWithSchema>> {
let mut views = Vec::new();
for schema in self.list_schemas(snapshot_id)? {
views.extend(
self.list_views(schema.schema_id, snapshot_id)?
.into_iter()
.map(|view| ViewWithSchema {
schema_name: schema.schema_name.clone(),
view,
}),
);
}
Ok(views)
}
fn list_all_columns(&self, snapshot_id: i64) -> Result<Vec<ColumnWithTable>>;
fn list_all_files(&self, snapshot_id: i64) -> Result<Vec<FileWithTable>>;
fn get_data_files_added_between_snapshots(
&self,
table_id: i64,
start_snapshot: i64,
end_snapshot: i64,
) -> Result<Vec<DataFileChange>>;
fn get_delete_files_added_between_snapshots(
&self,
table_id: i64,
start_snapshot: i64,
end_snapshot: i64,
) -> Result<Vec<DeleteFileChange>>;
}
#[cfg(any(feature = "metadata-postgres", feature = "metadata-mysql", feature = "metadata-sqlite"))]
pub(crate) fn block_on<F, T>(f: F) -> T
where
F: std::future::Future<Output = T>,
{
tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(f))
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use arrow::datatypes::{DataType, Field, Schema};
use super::*;
fn column(name: &str, column_type: &str) -> DuckLakeTableColumn {
DuckLakeTableColumn::new(1, name.to_string(), column_type.to_string(), true)
}
#[test]
fn inlined_text_projections_match_backend_encodings() {
assert_eq!(
inlined_text_projection(
InlinedDataBackend::Postgres,
&column("note", "varchar"),
&DataType::Utf8View,
"\"note\"",
),
"convert_from(\"note\", 'UTF8')"
);
assert_eq!(
inlined_text_projection(
InlinedDataBackend::Postgres,
&column("payload", "blob"),
&DataType::BinaryView,
"\"payload\"",
),
"encode(\"payload\", 'hex')"
);
assert_eq!(
inlined_text_projection(
InlinedDataBackend::Postgres,
&column("token", "uuid"),
&DataType::FixedSizeBinary(16),
"\"token\"",
),
"CAST(\"token\" AS TEXT)"
);
assert_eq!(
inlined_text_projection(
InlinedDataBackend::MySql,
&column("payload", "blob"),
&DataType::BinaryView,
"`payload`",
),
"HEX(`payload`)"
);
assert_eq!(
inlined_text_projection(
InlinedDataBackend::MySql,
&column("note", "varchar"),
&DataType::Utf8View,
"`note`",
),
"CAST(`note` AS CHAR CHARACTER SET utf8mb4)"
);
}
#[test]
fn resolve_metadata_settings_uses_per_key_scope_precedence() {
let rows = vec![
MetadataSetting {
key: "compression".into(),
value: "global".into(),
scope: None,
scope_id: None,
},
MetadataSetting {
key: "rows".into(),
value: "global-rows".into(),
scope: None,
scope_id: None,
},
MetadataSetting {
key: "compression".into(),
value: "schema".into(),
scope: Some("schema".into()),
scope_id: Some(7),
},
MetadataSetting {
key: "compression".into(),
value: "other-schema".into(),
scope: Some("schema".into()),
scope_id: Some(8),
},
MetadataSetting {
key: "compression".into(),
value: "table".into(),
scope: Some("table".into()),
scope_id: Some(11),
},
];
let settings = resolve_metadata_settings(rows, Some(7), Some(11)).unwrap();
assert_eq!(
settings,
HashMap::from([
("compression".to_string(), "table".to_string()),
("rows".to_string(), "global-rows".to_string()),
])
);
}
#[test]
fn inlined_rows_parse_nested_encoding() {
let columns = vec![column("items", "list<int32>")];
let schema = Arc::new(Schema::new(vec![Field::new_list(
"items",
Field::new("item", DataType::Int32, true),
true,
)]));
let batch =
parse_inlined_rows(schema, &columns, vec![vec![Some("[1, 2]".to_string())]]).unwrap();
assert_eq!(
ScalarValue::try_from_array(batch.column(0), 0)
.unwrap()
.to_string(),
"[1, 2]"
);
}
#[test]
fn resolve_metadata_settings_rejects_scoped_row_without_id() {
let error = resolve_metadata_settings(
vec![MetadataSetting {
key: "compression".into(),
value: "zstd".into(),
scope: Some("table".into()),
scope_id: None,
}],
Some(7),
Some(11),
)
.unwrap_err();
assert_eq!(
error.to_string(),
"Invalid configuration: ducklake_metadata table setting 'compression' has no scope_id"
);
}
#[test]
fn test_reconstruct_columns_list() {
let rows = vec![
(
DuckLakeTableColumn::new(1, "id".into(), "int64".into(), false),
None,
),
(
DuckLakeTableColumn::new(6, "vector".into(), "list".into(), true),
None,
),
(
DuckLakeTableColumn::new(7, "element".into(), "float64".into(), true),
Some(6),
),
];
let result = reconstruct_columns(rows).unwrap();
assert_eq!(result.len(), 2);
assert_eq!(result[0].column_name, "id");
assert_eq!(result[0].column_type, "int64");
assert_eq!(result[1].column_name, "vector");
assert_eq!(result[1].column_type, "list<float64>");
}
#[test]
fn test_reconstruct_columns_scalars() {
let rows = vec![
(
DuckLakeTableColumn::new(1, "id".into(), "int64".into(), false),
None,
),
(
DuckLakeTableColumn::new(2, "name".into(), "varchar".into(), true),
None,
),
];
let result = reconstruct_columns(rows).unwrap();
assert_eq!(result.len(), 2);
assert_eq!(result[0].column_type, "int64");
assert_eq!(result[1].column_type, "varchar");
}
#[test]
fn test_reconstruct_columns_preserves_scalar_catalog_names() {
let rows = vec![
(
DuckLakeTableColumn::new(1, "shape".into(), "geometry".into(), true),
None,
),
(
DuckLakeTableColumn::new(2, "details".into(), "json".into(), true),
None,
),
(
DuckLakeTableColumn::new(3, "at".into(), "timetz".into(), true),
None,
),
(
DuckLakeTableColumn::new(4, "count".into(), "INT".into(), true),
None,
),
];
let result = reconstruct_columns(rows).unwrap();
assert_eq!(result[0].column_type, "geometry");
assert_eq!(result[1].column_type, "json");
assert_eq!(result[2].column_type, "timetz");
assert_eq!(result[3].column_type, "int32");
}
#[test]
fn test_reconstruct_columns_depth_is_bounded() {
let count = crate::types::MAX_NESTED_TYPE_DEPTH + 2;
let rows = (0..count)
.map(|index| {
let column_id = index as i64 + 1;
let column_type = if index + 1 == count {
"int32"
} else {
"struct"
};
(
DuckLakeTableColumn::new(
column_id,
format!("field_{index}"),
column_type.to_string(),
true,
),
(index > 0).then_some(column_id - 1),
)
})
.collect();
let error = reconstruct_columns(rows).unwrap_err();
assert!(error.to_string().contains("maximum depth"));
}
#[test]
fn test_reconstruct_columns_struct() {
let rows = vec![
(
DuckLakeTableColumn::new(1, "data".into(), "struct".into(), true),
None,
),
(
DuckLakeTableColumn::new(2, "field_a".into(), "int32".into(), true),
Some(1),
),
];
let result = reconstruct_columns(rows).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].column_type, "struct<field_a:int32>");
assert_eq!(result[0].nested_column_ids, vec![2]);
}
#[test]
fn test_reconstruct_columns_arbitrary_nesting() {
let rows = vec![
(
DuckLakeTableColumn::new(1, "payload".into(), "struct".into(), false),
None,
),
(
DuckLakeTableColumn::new(2, "levels".into(), "list".into(), false),
Some(1),
),
(
DuckLakeTableColumn::new(3, "element".into(), "struct".into(), false),
Some(2),
),
(
DuckLakeTableColumn::new(4, "price".into(), "decimal(38, 16)".into(), false),
Some(3),
),
(
DuckLakeTableColumn::new(5, "attrs".into(), "map".into(), true),
Some(1),
),
(
DuckLakeTableColumn::new(6, "key".into(), "varchar".into(), false),
Some(5),
),
(
DuckLakeTableColumn::new(7, "value".into(), "list".into(), true),
Some(5),
),
(
DuckLakeTableColumn::new(8, "element".into(), "int32".into(), true),
Some(7),
),
];
let result = reconstruct_columns(rows).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(
result[0].column_type,
"struct<levels:list<struct<price:decimal(38, 16)>>,attrs:map<varchar,list<int32>>>"
);
assert_eq!(result[0].nested_column_ids, vec![2, 3, 4, 5, 6, 7, 8]);
}
#[test]
fn test_reconstruct_columns_rejects_invalid_map_children() {
let rows = vec![
(
DuckLakeTableColumn::new(1, "attrs".into(), "map".into(), true),
None,
),
(
DuckLakeTableColumn::new(2, "value".into(), "int32".into(), true),
Some(1),
),
(
DuckLakeTableColumn::new(3, "key".into(), "varchar".into(), false),
Some(1),
),
];
assert!(reconstruct_columns(rows).is_err());
}
#[test]
fn test_reconstruct_columns_rejects_duplicate_ids() {
let rows = vec![
(
DuckLakeTableColumn::new(1, "id".into(), "int64".into(), false),
None,
),
(
DuckLakeTableColumn::new(1, "name".into(), "varchar".into(), true),
None,
),
];
assert!(reconstruct_columns(rows).is_err());
}
#[test]
fn test_reconstruct_columns_rejects_parent_cycle() {
let rows = vec![
(
DuckLakeTableColumn::new(1, "left".into(), "struct".into(), false),
Some(2),
),
(
DuckLakeTableColumn::new(2, "right".into(), "struct".into(), false),
Some(1),
),
];
assert!(reconstruct_columns(rows).is_err());
}
#[test]
fn test_reconstruct_columns_multiple_lists() {
let rows = vec![
(
DuckLakeTableColumn::new(1, "tags".into(), "list".into(), true),
None,
),
(
DuckLakeTableColumn::new(2, "element".into(), "varchar".into(), true),
Some(1),
),
(
DuckLakeTableColumn::new(3, "scores".into(), "list".into(), true),
None,
),
(
DuckLakeTableColumn::new(4, "element".into(), "float64".into(), true),
Some(3),
),
];
let result = reconstruct_columns(rows).unwrap();
assert_eq!(result.len(), 2);
assert_eq!(result[0].column_type, "list<varchar>");
assert_eq!(result[1].column_type, "list<float64>");
}
#[test]
fn test_reconstruct_columns_with_table_list() {
let rows = vec![
(
ColumnWithTable {
schema_name: "main".into(),
table_name: "t".into(),
column: DuckLakeTableColumn::new(6, "vector".into(), "list".into(), true),
},
None,
),
(
ColumnWithTable {
schema_name: "main".into(),
table_name: "t".into(),
column: DuckLakeTableColumn::new(7, "element".into(), "float64".into(), true),
},
Some(6),
),
];
let result = reconstruct_columns_with_table(rows).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].column.column_type, "list<float64>");
}
}