use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use std::sync::atomic::{AtomicI64, Ordering};
use arrow::array::{Array, ArrayRef, Int64Array, RecordBatch};
use arrow::compute::SortOptions;
use arrow::datatypes::{DataType, Field, Schema, SchemaRef};
use datafusion::catalog::Session;
use datafusion::common::tree_node::TreeNodeRecursion;
use datafusion::datasource::memory::MemorySourceConfig;
use datafusion::error::{DataFusionError, Result as DataFusionResult};
use datafusion::execution::{SendableRecordBatchStream, TaskContext};
use datafusion::physical_expr::PhysicalExpr;
use datafusion::physical_expr::{
EquivalenceProperties, LexOrdering, PhysicalSortExpr, expressions::Column,
};
use datafusion::physical_plan::coalesce_partitions::CoalescePartitionsExec;
use datafusion::physical_plan::stream::RecordBatchStreamAdapter;
use datafusion::physical_plan::union::UnionExec;
use datafusion::physical_plan::{
DisplayAs, DisplayFormatType, ExecutionPlan, ExecutionPlanProperties, PlanProperties,
sorts::sort::SortExec,
};
use futures::{StreamExt, TryStreamExt};
use crate::column_rename::ColumnRenameExec;
use crate::metadata_provider::DuckLakeTableFile;
use crate::metadata_writer::{CompactionOutputFile, CompactionSourceFile, SourceRetirement};
use crate::partition::PartitionSpec;
use crate::row_id::EMBEDDED_SNAPSHOT_ID_COLUMN_NAME;
use crate::sort::{SortDirection, SortSpec};
use crate::table::{
DuckLakeTable, MergeSourceFacts, RewrittenBatch, UpdateSourceScan, rewrite_output_schema,
rewrite_scanned_batch,
};
use crate::table_writer::DuckLakeTableWriter;
use crate::{DuckLakeError, Result};
#[derive(Debug, Clone)]
pub struct MergeOptions {
pub target_file_size: u64,
pub max_merged_files: usize,
pub min_file_size: u64,
}
impl Default for MergeOptions {
fn default() -> Self {
Self {
target_file_size: crate::table_writer::DEFAULT_TARGET_FILE_SIZE as u64,
max_merged_files: 1024,
min_file_size: 0,
}
}
}
#[derive(Debug, Clone)]
pub struct RewriteOptions {
pub delete_threshold: f64,
pub data_file_ids: Option<Vec<i64>>,
}
impl Default for RewriteOptions {
fn default() -> Self {
Self {
delete_threshold: 0.95,
data_file_ids: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CompactionResult {
pub files_processed: usize,
pub files_created: usize,
pub rows_written: i64,
}
impl CompactionResult {
fn empty() -> Self {
Self {
files_processed: 0,
files_created: 0,
rows_written: 0,
}
}
pub fn did_work(&self) -> bool {
self.files_processed > 0
}
}
fn snapshot_column_field() -> Field {
Field::new(EMBEDDED_SNAPSHOT_ID_COLUMN_NAME, DataType::Int64, true)
}
#[derive(Debug, Clone, Copy)]
enum OriginSource {
Constant(i64),
Embedded,
}
fn resolve_origin_column(
origin: OriginSource,
carried: Option<&ArrayRef>,
rows: usize,
source_path: &str,
) -> DataFusionResult<ArrayRef> {
if let OriginSource::Constant(origin) = origin {
return Ok(Arc::new(Int64Array::from(vec![origin; rows])));
}
let carried = carried.ok_or_else(|| {
DataFusionError::Internal(format!(
"merge source \"{source_path}\" was planned with per-row origins but its scan did \
not project the embedded snapshot-id column"
))
})?;
let origins = carried
.as_any()
.downcast_ref::<Int64Array>()
.ok_or_else(|| {
DataFusionError::Internal(format!(
"embedded snapshot-id column of \"{source_path}\" is not Int64"
))
})?;
if origins.null_count() > 0 {
return Err(DataFusionError::Execution(format!(
"merge source \"{source_path}\" has NULL values in its embedded \
`_ducklake_internal_snapshot_id` column; refusing to merge rows whose origin \
snapshot is unknown"
)));
}
Ok(Arc::clone(carried))
}
fn append_snapshot_column(
batch: &RecordBatch,
origins: ArrayRef,
schema: &SchemaRef,
) -> DataFusionResult<RecordBatch> {
let mut cols: Vec<ArrayRef> = batch.columns().to_vec();
cols.push(origins);
Ok(RecordBatch::try_new(Arc::clone(schema), cols)?)
}
#[derive(Debug)]
struct CompactionSourceExec {
scan: Arc<UpdateSourceScan>,
physical_schema: SchemaRef,
rewrite_schema: SchemaRef,
schema: SchemaRef,
origin: Option<OriginSource>,
observed_max_origin: Arc<AtomicI64>,
properties: Arc<PlanProperties>,
}
impl CompactionSourceExec {
fn new(
scan: Arc<UpdateSourceScan>,
physical_schema: SchemaRef,
origin: Option<OriginSource>,
observed_max_origin: Arc<AtomicI64>,
) -> Self {
let rewrite_schema = rewrite_output_schema(&physical_schema);
let schema = if origin.is_some() {
let mut fields: Vec<Arc<Field>> = rewrite_schema.fields().iter().cloned().collect();
fields.push(Arc::new(snapshot_column_field()));
Arc::new(Schema::new(fields))
} else {
Arc::clone(&rewrite_schema)
};
let properties = Arc::new(PlanProperties::new(
EquivalenceProperties::new(Arc::clone(&schema)),
scan.scan.output_partitioning().clone(),
scan.scan.pipeline_behavior(),
scan.scan.boundedness(),
));
Self {
scan,
physical_schema,
rewrite_schema,
schema,
origin,
observed_max_origin,
properties,
}
}
}
impl DisplayAs for CompactionSourceExec {
fn fmt_as(&self, _t: DisplayFormatType, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(
f,
"CompactionSourceExec: file={}, origin={}",
self.scan.source_path,
match self.origin {
None => "none".to_string(),
Some(OriginSource::Constant(origin)) => origin.to_string(),
Some(OriginSource::Embedded) => "embedded".to_string(),
}
)
}
}
impl ExecutionPlan for CompactionSourceExec {
fn apply_expressions(
&self,
_f: &mut dyn FnMut(&Arc<dyn PhysicalExpr>) -> DataFusionResult<TreeNodeRecursion>,
) -> DataFusionResult<TreeNodeRecursion> {
Ok(TreeNodeRecursion::Continue)
}
fn name(&self) -> &str {
"CompactionSourceExec"
}
fn properties(&self) -> &Arc<PlanProperties> {
&self.properties
}
fn children(&self) -> Vec<&Arc<dyn ExecutionPlan>> {
vec![&self.scan.scan]
}
fn maintains_input_order(&self) -> Vec<bool> {
vec![true]
}
fn with_new_children(
self: Arc<Self>,
children: Vec<Arc<dyn ExecutionPlan>>,
) -> DataFusionResult<Arc<dyn ExecutionPlan>> {
let [child] = <[Arc<dyn ExecutionPlan>; 1]>::try_from(children).map_err(|_| {
DataFusionError::Internal("CompactionSourceExec expects exactly one child".into())
})?;
let mut scan = UpdateSourceScan::clone(&self.scan);
scan.scan = child;
Ok(Arc::new(CompactionSourceExec::new(
Arc::new(scan),
Arc::clone(&self.physical_schema),
self.origin,
Arc::clone(&self.observed_max_origin),
)))
}
fn execute(
&self,
partition: usize,
context: Arc<TaskContext>,
) -> DataFusionResult<SendableRecordBatchStream> {
let input = self.scan.scan.execute(partition, context)?;
let scan = Arc::clone(&self.scan);
let physical_schema = Arc::clone(&self.physical_schema);
let rewrite_schema = Arc::clone(&self.rewrite_schema);
let schema = Arc::clone(&self.schema);
let origin = self.origin;
let observed_max_origin = Arc::clone(&self.observed_max_origin);
let stream = input
.map(move |batch| -> DataFusionResult<Option<RecordBatch>> {
let batch = batch?;
let Some(RewrittenBatch {
batch,
origin_snapshots,
..
}) = rewrite_scanned_batch(
&physical_schema,
&rewrite_schema,
&scan,
&batch,
None,
&[],
)?
else {
return Ok(None);
};
Ok(Some(match origin {
Some(origin) => {
let origins = resolve_origin_column(
origin,
origin_snapshots.as_ref(),
batch.num_rows(),
&scan.source_path,
)?;
let typed = origins
.as_any()
.downcast_ref::<arrow::array::Int64Array>()
.ok_or_else(|| {
DataFusionError::Internal(format!(
"compaction origin column for \"{}\" is {:?}, not Int64",
scan.source_path,
origins.data_type()
))
})?;
if let Some(batch_max) = arrow::compute::max(typed) {
observed_max_origin.fetch_max(batch_max, Ordering::Relaxed);
}
append_snapshot_column(&batch, origins, &schema)?
},
None => batch,
}))
})
.try_filter_map(|batch| std::future::ready(Ok(batch)));
Ok(Box::pin(RecordBatchStreamAdapter::new(
Arc::clone(&self.schema),
stream,
)))
}
}
async fn first_nonempty(
mut stream: SendableRecordBatchStream,
) -> Result<Option<SendableRecordBatchStream>> {
let schema = stream.schema();
while let Some(batch) = stream.next().await {
let batch = batch?;
if batch.num_rows() == 0 {
continue;
}
let head = futures::stream::once(std::future::ready(Ok(batch)));
return Ok(Some(Box::pin(RecordBatchStreamAdapter::new(
schema,
head.chain(stream),
))));
}
Ok(None)
}
fn partition_key(file: &DuckLakeTableFile) -> (Option<i64>, Vec<Option<String>>) {
let mut values = file.partition_values.clone();
values.sort_by_key(|(index, _)| *index);
(
file.partition_id,
values.into_iter().map(|(_, value)| value).collect(),
)
}
fn partition_value_pairs(values: &[Option<String>]) -> Vec<(i32, Option<String>)> {
values
.iter()
.enumerate()
.map(|(index, value)| (index as i32, value.clone()))
.collect()
}
pub(crate) fn compaction_ordering(
data_schema: &Schema,
sort_spec: Option<&SortSpec>,
) -> Result<Option<LexOrdering>> {
let Some(sort_spec) = sort_spec else {
return Ok(None);
};
let keys = sort_spec.producible_columns().ok_or_else(|| {
DuckLakeError::InvalidConfig(format!(
"DuckLake sort order {} contains an unsupported expression; \
datafusion-ducklake can write only bare-column sort keys",
sort_spec.sort_id
))
})?;
if keys.is_empty() {
return Ok(None);
}
let mut expressions = Vec::with_capacity(keys.len());
for (name, direction, null_order) in keys {
let index = data_schema.index_of(&name).map_err(|_| {
DuckLakeError::InvalidConfig(format!(
"DuckLake sort key '{name}' is not present in the rewrite schema"
))
})?;
expressions.push(PhysicalSortExpr::new(
Arc::new(Column::new(&name, index)),
SortOptions {
descending: direction == SortDirection::Desc,
nulls_first: null_order.nulls_first(),
},
));
}
Ok(Some(LexOrdering::new(expressions).ok_or_else(|| {
DuckLakeError::Internal("sort order is empty".to_string())
})?))
}
pub(crate) fn sorted_rewrite_batches(
context: Arc<TaskContext>,
batches: Vec<RecordBatch>,
ordering: Option<&LexOrdering>,
) -> Result<SendableRecordBatchStream> {
let schema = batches
.first()
.ok_or_else(|| DuckLakeError::Internal("cannot sort empty compaction input".to_string()))?
.schema();
let input = MemorySourceConfig::try_new_exec(&[batches], schema, None)?;
sorted_rewrite_output(context, input, ordering)
}
pub(crate) fn sorted_rewrite_output(
context: Arc<TaskContext>,
input: Arc<dyn ExecutionPlan>,
ordering: Option<&LexOrdering>,
) -> Result<SendableRecordBatchStream> {
let schema = input.schema();
let input: Arc<dyn ExecutionPlan> = if input.output_partitioning().partition_count() > 1 {
Arc::new(CoalescePartitionsExec::new(input))
} else {
input
};
let Some(ordering) = ordering else {
return Ok(input.execute(0, context)?);
};
let sorted: Arc<dyn ExecutionPlan> = Arc::new(SortExec::new(ordering.clone(), input));
let output = Arc::new(ColumnRenameExec::new(sorted, schema, HashMap::new()));
Ok(output.execute(0, context)?)
}
impl DuckLakeTable {
#[cfg(feature = "write")]
fn partition_path_names(
&self,
live: Option<&PartitionSpec>,
partition_id: i64,
column_ids: &[i64],
) -> Vec<String> {
let Some(spec) = live.filter(|spec| spec.partition_id == partition_id) else {
return Vec::new();
};
let schema = self.physical_schema();
let names: Option<Vec<String>> = spec
.columns
.iter()
.map(|column| {
let index = column_ids.iter().position(|id| *id == column.column_id)?;
Some(schema.field(index).name().to_string())
})
.collect();
names.unwrap_or_default()
}
pub async fn merge_adjacent_files(
&self,
state: &dyn Session,
opts: MergeOptions,
) -> Result<CompactionResult> {
let writer = self.writer().ok_or_else(|| {
DuckLakeError::InvalidConfig(
"merge_adjacent_files: table is read-only; open the catalog with a writer"
.to_string(),
)
})?;
let schema_name = self.schema_name().ok_or_else(|| {
DuckLakeError::Internal("writable table has no schema name".to_string())
})?;
self.write_options().validate()?;
if !self.write_options().auto_compact.unwrap_or(true) {
return Ok(CompactionResult::empty());
}
let table_files = self.files()?;
let inlined_deletes = self.inlined_deletes_by_file()?;
let mut candidates: Vec<&DuckLakeTableFile> = table_files
.iter()
.filter(|f| {
f.delete_file_id.is_none()
&& !inlined_deletes.contains_key(&f.data_file_id)
&& f.begin_snapshot.is_some()
&& f.schema_version.is_some()
&& (f.file.file_size_bytes as u64) >= opts.min_file_size
&& (f.file.file_size_bytes as u64) < opts.target_file_size
})
.collect();
candidates.sort_by_key(|f| {
(
f.schema_version.unwrap_or(0),
partition_key(f),
f.data_file_id,
)
});
candidates.truncate(opts.max_merged_files);
let mut bins: Vec<Vec<&DuckLakeTableFile>> = Vec::new();
let mut i = 0;
while i < candidates.len() {
let version = candidates[i].schema_version;
let partition = partition_key(candidates[i]);
let mut running: u64 = 0;
let mut bin: Vec<&DuckLakeTableFile> = Vec::new();
while i < candidates.len()
&& candidates[i].schema_version == version
&& partition_key(candidates[i]) == partition
{
bin.push(candidates[i]);
running += candidates[i].file.file_size_bytes as u64;
i += 1;
if running >= opts.target_file_size {
break;
}
}
if bin.len() >= 2 {
bins.push(bin);
}
}
if bins.is_empty() {
return Ok(CompactionResult::empty());
}
let mut source_facts: HashMap<i64, MergeSourceFacts> = HashMap::new();
let mut viable: Vec<Vec<&DuckLakeTableFile>> = Vec::with_capacity(bins.len());
for bin in bins {
let mut mergeable = true;
for tf in &bin {
let facts = self.merge_source_facts(state, &tf.file).await?;
source_facts.insert(tf.data_file_id, facts);
if facts.drops_current_columns {
mergeable = false;
} else if tf.partial_max.is_some() && !facts.has_embedded_snapshot {
tracing::warn!(
file = %tf.file.path,
partial_max = ?tf.partial_max,
"skipping merge bin: the catalog records this file as partial but it \
carries no embedded snapshot-id column"
);
mergeable = false;
} else if tf.partial_max.is_some_and(|pm| self.base_snapshot() < pm) {
tracing::warn!(
file = %tf.file.path,
partial_max = ?tf.partial_max,
base_snapshot = self.base_snapshot(),
"skipping merge bin: this table is opened below the snapshot the \
source's rows reach; re-open at the current snapshot to merge it"
);
mergeable = false;
}
if !mergeable {
break;
}
}
if mergeable {
viable.push(bin);
}
}
let bins = viable;
if bins.is_empty() {
return Ok(CompactionResult::empty());
}
let object_store = state
.runtime_env()
.object_store(self.object_store_url().as_ref())?;
let table_writer = DuckLakeTableWriter::new(Arc::clone(writer), object_store)?
.with_options(&self.write_options);
let column_ids = self.column_ids();
let top_level_column_ids = self.top_level_column_ids();
let physical_schema = self.physical_schema();
let sort_spec = self.live_sort_spec()?;
let ordering = compaction_ordering(physical_schema.as_ref(), sort_spec.as_ref())?;
let live_partition_spec = self.live_partition_spec()?;
let mut sources: Vec<CompactionSourceFile> = Vec::new();
let mut outputs: Vec<CompactionOutputFile> = Vec::new();
let mut files_processed = 0usize;
let mut rows_written = 0i64;
for bin in &bins {
let mut range_mins: Vec<i64> = Vec::with_capacity(bin.len());
let mut range_maxes: Vec<i64> = Vec::with_capacity(bin.len());
for tf in bin {
let begin = tf.begin_snapshot.ok_or_else(|| {
DuckLakeError::Internal("merge candidate missing begin_snapshot".to_string())
})?;
range_mins.push(begin);
range_maxes.push(tf.partial_max.unwrap_or(begin));
}
let min_origin = range_mins.iter().copied().min();
let max_origin = range_maxes.iter().copied().max();
let embeds_origins = |tf: &DuckLakeTableFile| {
source_facts
.get(&tf.data_file_id)
.is_some_and(|facts| facts.has_embedded_snapshot)
};
let partial = min_origin != max_origin || bin.iter().any(|tf| embeds_origins(tf));
let observed_max_origin = Arc::new(AtomicI64::new(max_origin.unwrap_or(i64::MIN)));
let mut leaves: Vec<Arc<dyn ExecutionPlan>> = Vec::with_capacity(bin.len());
for (tf, begin) in bin.iter().zip(&range_mins) {
let source_embeds_origins = embeds_origins(tf);
let origin = partial.then_some(if source_embeds_origins {
OriginSource::Embedded
} else {
OriginSource::Constant(*begin)
});
let scan = self
.build_update_scan_with_snapshot(
state,
tf,
source_embeds_origins,
inlined_deletes.get(&tf.data_file_id),
)
.await?;
leaves.push(Arc::new(CompactionSourceExec::new(
Arc::new(scan),
Arc::clone(&physical_schema),
origin,
Arc::clone(&observed_max_origin),
)));
sources.push(CompactionSourceFile {
data_file_id: tf.data_file_id,
delete_file_id: None,
});
files_processed += 1;
}
let merged = sorted_rewrite_output(
state.task_ctx(),
UnionExec::try_new(leaves)?,
ordering.as_ref(),
)?;
let Some(merged) = first_nonempty(merged).await? else {
continue;
};
let (partition_id, partition_values) = partition_key(bin[0]);
let subpath = partition_id
.filter(|_| self.write_options().hive_file_pattern.unwrap_or(true))
.map(|pid| {
let names = self.partition_path_names(
live_partition_spec.as_ref(),
pid,
&top_level_column_ids,
);
crate::partition::hive_subpath(&names, &partition_values)
});
let file = table_writer
.write_compacted_file_stream(
schema_name,
self.table_name(),
physical_schema.as_ref(),
&column_ids,
&top_level_column_ids,
merged,
partial,
subpath.as_deref(),
)
.await?;
rows_written += file.record_count;
let file = match partition_id {
Some(pid) => file.with_partition(pid, partition_value_pairs(&partition_values)),
None => file,
};
let partial_max = partial.then(|| observed_max_origin.load(Ordering::Relaxed));
outputs.push(CompactionOutputFile {
file,
partial_max,
begin_snapshot: min_origin,
});
}
if sources.is_empty() {
return Ok(CompactionResult::empty());
}
writer.commit_compaction(
self.table_id(),
self.base_snapshot(),
&sources,
&outputs,
SourceRetirement::Remove,
)?;
Ok(CompactionResult {
files_processed,
files_created: outputs.len(),
rows_written,
})
}
pub async fn rewrite_data_files(
&self,
state: &dyn Session,
mut opts: RewriteOptions,
) -> Result<CompactionResult> {
self.write_options().validate()?;
if !self.write_options().auto_compact.unwrap_or(true) {
return Ok(CompactionResult::empty());
}
if opts.delete_threshold == RewriteOptions::default().delete_threshold {
opts.delete_threshold = self
.write_options()
.rewrite_delete_threshold
.unwrap_or(opts.delete_threshold);
}
if !(0.0..=1.0).contains(&opts.delete_threshold) {
return Err(DuckLakeError::InvalidConfig(format!(
"rewrite_data_files: delete_threshold must be in [0.0, 1.0], got {}",
opts.delete_threshold
)));
}
let writer = self.writer().ok_or_else(|| {
DuckLakeError::InvalidConfig(
"rewrite_data_files: table is read-only; open the catalog with a writer"
.to_string(),
)
})?;
let schema_name = self.schema_name().ok_or_else(|| {
DuckLakeError::Internal("writable table has no schema name".to_string())
})?;
let object_store = state
.runtime_env()
.object_store(self.object_store_url().as_ref())?;
let table_writer = DuckLakeTableWriter::new(Arc::clone(writer), object_store)?
.with_options(&self.write_options);
let column_ids = self.column_ids();
let top_level_column_ids = self.top_level_column_ids();
let physical_schema = self.physical_schema();
let selected_ids = opts
.data_file_ids
.map(|ids| ids.into_iter().collect::<HashSet<_>>());
let table_files = self.files()?;
let inlined_deletes = self.inlined_deletes_by_file()?;
let candidates: Vec<&DuckLakeTableFile> = table_files
.iter()
.filter(|tf| match &selected_ids {
Some(selected_ids) => selected_ids.contains(&tf.data_file_id),
None => {
let record_count = tf.max_row_count.unwrap_or(0);
let inlined_count = inlined_deletes
.get(&tf.data_file_id)
.map_or(0, |positions| positions.len() as i64);
let delete_count = tf.delete_count.unwrap_or(0) + inlined_count;
delete_count > 0
&& record_count > 0
&& delete_count as f64 / record_count as f64 >= opts.delete_threshold
},
})
.collect();
if candidates.is_empty() {
return Ok(CompactionResult::empty());
}
let sort_spec = self.live_sort_spec()?;
let ordering = compaction_ordering(physical_schema.as_ref(), sort_spec.as_ref())?;
let live_partition_spec = self.live_partition_spec()?;
let mut sources: Vec<CompactionSourceFile> = Vec::new();
let mut outputs: Vec<CompactionOutputFile> = Vec::new();
let mut files_processed = 0usize;
let mut rows_written = 0i64;
for tf in candidates {
let scan = self
.build_update_scan(state, tf, inlined_deletes.get(&tf.data_file_id))
.await?;
let sorted = sorted_rewrite_output(
state.task_ctx(),
Arc::new(CompactionSourceExec::new(
Arc::new(scan),
Arc::clone(&physical_schema),
None,
Arc::new(AtomicI64::new(i64::MIN)),
)),
ordering.as_ref(),
)?;
files_processed += 1;
sources.push(CompactionSourceFile {
data_file_id: tf.data_file_id,
delete_file_id: tf.delete_file_id,
});
let Some(sorted) = first_nonempty(sorted).await? else {
continue;
};
let (partition_id, partition_values) = partition_key(tf);
let subpath = partition_id
.filter(|_| self.write_options().hive_file_pattern.unwrap_or(true))
.map(|pid| {
let names = self.partition_path_names(
live_partition_spec.as_ref(),
pid,
&top_level_column_ids,
);
crate::partition::hive_subpath(&names, &partition_values)
});
let file = table_writer
.write_compacted_file_stream(
schema_name,
self.table_name(),
physical_schema.as_ref(),
&column_ids,
&top_level_column_ids,
sorted,
false,
subpath.as_deref(),
)
.await?;
rows_written += file.record_count;
let file = match partition_id {
Some(pid) => file.with_partition(pid, partition_value_pairs(&partition_values)),
None => file,
};
outputs.push(CompactionOutputFile {
file,
partial_max: None,
begin_snapshot: None,
});
}
if sources.is_empty() {
return Ok(CompactionResult::empty());
}
writer.commit_compaction(
self.table_id(),
self.base_snapshot(),
&sources,
&outputs,
SourceRetirement::Retire,
)?;
Ok(CompactionResult {
files_processed,
files_created: outputs.len(),
rows_written,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sort::{DUCKDB_DIALECT, NullOrder, SortDirection, SortField};
#[test]
fn compaction_ordering_rejects_expression_sort_key() {
let data_schema = Schema::new(vec![Field::new("id", DataType::Int64, false)]);
let sort_spec = SortSpec {
sort_id: 7,
fields: vec![SortField {
sort_key_index: 0,
expression: "lower(id)".to_string(),
dialect: DUCKDB_DIALECT.to_string(),
direction: SortDirection::Asc,
null_order: NullOrder::NullsLast,
}],
};
let result = compaction_ordering(&data_schema, Some(&sort_spec));
let err = match result {
Ok(_) => panic!("expression sort key must be rejected"),
Err(e) => e,
};
assert_eq!(
err.to_string(),
"Invalid configuration: DuckLake sort order 7 contains an unsupported expression; \
datafusion-ducklake can write only bare-column sort keys",
);
}
}