use std::collections::{HashMap, HashSet};
use std::fmt;
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use std::task::{Context, Poll};
use arrow::array::{Array, ArrayRef, BooleanArray, Int64Array, StringArray, UInt32Array};
use arrow::compute::take;
use arrow::datatypes::{DataType, Field, FieldRef, Schema, SchemaRef};
use arrow::record_batch::RecordBatch;
use async_trait::async_trait;
use datafusion::catalog::Session;
use datafusion::common::Result as DataFusionResult;
use datafusion::datasource::listing::PartitionedFile;
use datafusion::datasource::physical_plan::{FileGroup, FileScanConfigBuilder, ParquetSource};
use datafusion::datasource::source::DataSourceExec;
use datafusion::datasource::{TableProvider, TableType};
use datafusion::error::DataFusionError;
use datafusion::execution::object_store::ObjectStoreUrl;
use datafusion::execution::{RecordBatchStream, SendableRecordBatchStream, TaskContext};
use datafusion::physical_expr::expressions::Column;
use datafusion::physical_expr::{EquivalenceProperties, PhysicalExpr};
use datafusion::physical_plan::projection::ProjectionExec;
use datafusion::physical_plan::union::UnionExec;
use datafusion::physical_plan::{
DisplayAs, DisplayFormatType, ExecutionPlan, ExecutionPlanProperties, PlanProperties,
};
use futures::{Stream, StreamExt};
use crate::column_rename::ColumnRenameExec;
use crate::metadata_provider::{DataFileChange, DuckLakeTableColumn, MetadataProvider};
use crate::path_resolver::resolve_path;
use crate::positional_source::PositionalFileSource;
use crate::row_id::{FileRowNumberExec, ROW_POS_COLUMN_NAME, SNAPSHOT_ID_PARQUET_FIELD_ID};
use crate::table::{
ParquetFileLayout, delete_file_schema, read_parquet_file_layout, read_parquet_footer_facts,
validated_file_size, validated_record_count,
};
use crate::types::ABSENT_FIELD_PREFIX;
#[cfg(feature = "encryption")]
use crate::encryption::EncryptionFactoryBuilder;
#[cfg(feature = "encryption")]
use datafusion::execution::parquet_encryption::EncryptionFactory;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChangeType {
Insert,
Delete,
UpdatePreimage,
UpdatePostimage,
}
impl ChangeType {
fn as_str(&self) -> &'static str {
match self {
ChangeType::Insert => "insert",
ChangeType::Delete => "delete",
ChangeType::UpdatePreimage => "update_preimage",
ChangeType::UpdatePostimage => "update_postimage",
}
}
}
impl fmt::Display for ChangeType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.as_str())
}
}
pub(crate) fn present_catalog_schema(
scan: Arc<dyn ExecutionPlan>,
table_fields: &[FieldRef],
name_mapping: &HashMap<String, String>,
) -> Arc<dyn ExecutionPlan> {
let scan_schema = scan.schema();
debug_assert!(scan_schema.fields().len() >= table_fields.len());
let mut fields: Vec<FieldRef> = table_fields.to_vec();
fields.extend(
scan_schema
.fields()
.iter()
.skip(table_fields.len())
.cloned(),
);
let output_schema: SchemaRef = Arc::new(Schema::new(fields));
if !name_mapping.is_empty() || scan_schema != output_schema {
Arc::new(ColumnRenameExec::new(
scan,
output_schema,
name_mapping.clone(),
))
} else {
scan
}
}
pub(crate) fn check_column_count(table_len: usize, columns_len: usize) -> DataFusionResult<()> {
if table_len != columns_len {
return Err(DataFusionError::External(
format!(
"change feed built with {columns_len} column(s) but a {table_len}-field table \
schema; the schema and the column list must describe the same table"
)
.into(),
));
}
Ok(())
}
fn row_count_probe_index(read_schema: &Schema) -> usize {
read_schema
.fields()
.iter()
.position(|f| !f.name().starts_with(ABSENT_FIELD_PREFIX))
.unwrap_or(0)
}
const SNAPSHOT_ID_IDX: usize = 0;
const ROWID_IDX: usize = 1;
const CHANGE_TYPE_IDX: usize = 2;
const CDC_COLS: usize = 3;
#[derive(Debug)]
pub struct PrependCDCColumnsExec {
input: Arc<dyn ExecutionPlan>,
snapshot_id: i64,
change_type: ChangeType,
include_rowid: bool,
include_snapshot_id: bool,
include_change_type: bool,
skip_input_columns: bool,
output_schema: SchemaRef,
properties: Arc<PlanProperties>,
}
impl PrependCDCColumnsExec {
#[allow(clippy::too_many_arguments)]
pub fn new(
input: Arc<dyn ExecutionPlan>,
snapshot_id: i64,
change_type: ChangeType,
include_rowid: bool,
include_snapshot_id: bool,
include_change_type: bool,
skip_input_columns: bool,
output_schema: SchemaRef,
) -> Self {
let eq_properties = EquivalenceProperties::new(output_schema.clone());
let properties = Arc::new(PlanProperties::new(
eq_properties,
input.output_partitioning().clone(),
input.pipeline_behavior(),
input.boundedness(),
));
Self {
input,
snapshot_id,
change_type,
include_rowid,
include_snapshot_id,
include_change_type,
skip_input_columns,
output_schema,
properties,
}
}
}
impl DisplayAs for PrependCDCColumnsExec {
fn fmt_as(&self, t: DisplayFormatType, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match t {
DisplayFormatType::Default
| DisplayFormatType::Verbose
| DisplayFormatType::TreeRender => {
write!(
f,
"PrependCDCColumnsExec: snapshot_id={}, change_type={}, \
include_snapshot={}, include_change={}, skip_input={}",
self.snapshot_id,
self.change_type,
self.include_snapshot_id,
self.include_change_type,
self.skip_input_columns
)
},
}
}
}
impl ExecutionPlan for PrependCDCColumnsExec {
fn name(&self) -> &str {
"PrependCDCColumnsExec"
}
fn properties(&self) -> &Arc<PlanProperties> {
&self.properties
}
fn children(&self) -> Vec<&Arc<dyn ExecutionPlan>> {
vec![&self.input]
}
fn with_new_children(
self: Arc<Self>,
children: Vec<Arc<dyn ExecutionPlan>>,
) -> DataFusionResult<Arc<dyn ExecutionPlan>> {
if children.len() != 1 {
return Err(DataFusionError::Internal(
"PrependCDCColumnsExec expects exactly one child".into(),
));
}
Ok(Arc::new(PrependCDCColumnsExec::new(
children[0].clone(),
self.snapshot_id,
self.change_type,
self.include_rowid,
self.include_snapshot_id,
self.include_change_type,
self.skip_input_columns,
self.output_schema.clone(),
)))
}
fn schema(&self) -> SchemaRef {
self.output_schema.clone()
}
fn execute(
&self,
partition: usize,
context: Arc<TaskContext>,
) -> DataFusionResult<SendableRecordBatchStream> {
let input_stream = self.input.execute(partition, context)?;
Ok(Box::pin(PrependCDCColumnsStream {
input: input_stream,
snapshot_id: self.snapshot_id,
change_type: self.change_type,
include_rowid: self.include_rowid,
include_snapshot_id: self.include_snapshot_id,
include_change_type: self.include_change_type,
skip_input_columns: self.skip_input_columns,
output_schema: self.output_schema.clone(),
}))
}
}
struct PrependCDCColumnsStream {
input: SendableRecordBatchStream,
snapshot_id: i64,
change_type: ChangeType,
include_rowid: bool,
include_snapshot_id: bool,
include_change_type: bool,
skip_input_columns: bool,
output_schema: SchemaRef,
}
impl Stream for PrependCDCColumnsStream {
type Item = DataFusionResult<RecordBatch>;
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
match Pin::new(&mut self.input).poll_next(cx) {
Poll::Ready(Some(Ok(batch))) => {
let result = self.transform_batch(&batch);
Poll::Ready(Some(result))
},
Poll::Ready(Some(Err(e))) => Poll::Ready(Some(Err(e))),
Poll::Ready(None) => Poll::Ready(None),
Poll::Pending => Poll::Pending,
}
}
}
impl PrependCDCColumnsStream {
fn transform_batch(&self, batch: &RecordBatch) -> DataFusionResult<RecordBatch> {
let num_rows = batch.num_rows();
let mut columns: Vec<ArrayRef> = Vec::new();
if self.include_snapshot_id {
columns.push(Arc::new(Int64Array::from(vec![self.snapshot_id; num_rows])));
}
if self.include_rowid {
columns.push(Arc::new(Int64Array::from(vec![None::<i64>; num_rows])));
}
if self.include_change_type {
columns.push(Arc::new(StringArray::from(vec![
self.change_type.as_str();
num_rows
])));
}
if !self.skip_input_columns {
columns.extend(batch.columns().iter().cloned());
}
RecordBatch::try_new_with_options(
self.output_schema.clone(),
columns,
&arrow::record_batch::RecordBatchOptions::new().with_row_count(Some(num_rows)),
)
.map_err(|e| DataFusionError::ArrowError(Box::new(e), None))
}
}
impl RecordBatchStream for PrependCDCColumnsStream {
fn schema(&self) -> SchemaRef {
self.output_schema.clone()
}
}
struct ProjectionInfo {
table_indices: Vec<usize>,
need_rowid: bool,
need_snapshot_id: bool,
need_change_type: bool,
output_schema: SchemaRef,
}
#[derive(Debug)]
pub struct TableChangesTable {
provider: Arc<dyn MetadataProvider>,
table_id: i64,
start_snapshot: i64,
end_snapshot: i64,
object_store_url: Arc<ObjectStoreUrl>,
table_path: String,
table_schema: SchemaRef,
output_schema: SchemaRef,
columns: Option<Arc<Vec<DuckLakeTableColumn>>>,
layout_cache: Mutex<HashMap<String, Arc<ParquetFileLayout>>>,
insertions_only: bool,
}
impl TableChangesTable {
pub fn new(
provider: Arc<dyn MetadataProvider>,
table_id: i64,
start_snapshot: i64,
end_snapshot: i64,
object_store_url: Arc<ObjectStoreUrl>,
table_path: String,
table_schema: SchemaRef,
) -> Self {
let mut fields: Vec<Field> = Vec::with_capacity(table_schema.fields().len() + CDC_COLS);
fields.push(Field::new("snapshot_id", DataType::Int64, false));
fields.push(Field::new("rowid", DataType::Int64, true));
fields.push(Field::new("change_type", DataType::Utf8, false));
fields.extend(table_schema.fields().iter().map(|f| f.as_ref().clone()));
let output_schema = Arc::new(Schema::new(fields));
Self {
provider,
table_id,
start_snapshot,
end_snapshot,
object_store_url,
table_path,
table_schema,
output_schema,
columns: None,
layout_cache: Mutex::new(HashMap::new()),
insertions_only: false,
}
}
pub fn with_columns(mut self, columns: Vec<DuckLakeTableColumn>) -> Self {
self.columns = Some(Arc::new(columns));
self
}
pub fn insertions_only(mut self) -> Self {
self.insertions_only = true;
self
}
fn resolve_columns(&self) -> DataFusionResult<Arc<Vec<DuckLakeTableColumn>>> {
match &self.columns {
Some(columns) => Ok(Arc::clone(columns)),
None => {
let columns = self
.provider
.get_table_structure(self.table_id, self.end_snapshot)
.map_err(|e| DataFusionError::External(Box::new(e)))?;
Ok(Arc::new(columns))
},
}
}
async fn file_layout(
&self,
state: &dyn Session,
columns: &[DuckLakeTableColumn],
path: &str,
is_relative: bool,
) -> DataFusionResult<Arc<ParquetFileLayout>> {
let resolved = resolve_path(&self.table_path, path, is_relative)
.map_err(|e| DataFusionError::External(Box::new(e)))?;
{
let cache = self.layout_cache.lock().unwrap();
if let Some(layout) = cache.get(&resolved) {
return Ok(Arc::clone(layout));
}
}
let layout = read_parquet_file_layout(
state,
self.object_store_url.as_ref(),
&resolved,
None,
columns,
&self.table_schema,
)
.await?;
self.layout_cache
.lock()
.unwrap()
.entry(resolved)
.or_insert_with(|| Arc::clone(&layout));
Ok(layout)
}
fn analyze_projection(&self, projection: Option<&Vec<usize>>) -> ProjectionInfo {
let num_table_cols = self.table_schema.fields().len();
match projection {
None => {
ProjectionInfo {
table_indices: (0..num_table_cols).collect(),
need_rowid: true,
need_snapshot_id: true,
need_change_type: true,
output_schema: self.output_schema.clone(),
}
},
Some(indices) => {
let mut table_indices: Vec<usize> = Vec::new();
let mut need_rowid = false;
let mut need_snapshot_id = false;
let mut need_change_type = false;
for &idx in indices {
match idx {
SNAPSHOT_ID_IDX => need_snapshot_id = true,
ROWID_IDX => need_rowid = true,
CHANGE_TYPE_IDX => need_change_type = true,
_ if idx < num_table_cols + CDC_COLS => {
table_indices.push(idx - CDC_COLS);
},
_ => {},
}
}
let mut fields: Vec<Field> = Vec::with_capacity(indices.len());
for &idx in indices {
fields.push(self.output_schema.field(idx).clone());
}
let output_schema = Arc::new(Schema::new(fields));
ProjectionInfo {
table_indices,
need_rowid,
need_snapshot_id,
need_change_type,
output_schema,
}
},
}
}
fn build_cdc_exec_schema(
&self,
table_indices: &[usize],
need_rowid: bool,
need_snapshot_id: bool,
need_change_type: bool,
) -> SchemaRef {
let mut fields: Vec<Field> = Vec::with_capacity(table_indices.len() + CDC_COLS);
if need_snapshot_id {
fields.push(Field::new("snapshot_id", DataType::Int64, false));
}
if need_rowid {
fields.push(Field::new("rowid", DataType::Int64, true));
}
if need_change_type {
fields.push(Field::new("change_type", DataType::Utf8, false));
}
fields.extend(
table_indices
.iter()
.map(|&i| self.table_schema.field(i).clone()),
);
Arc::new(Schema::new(fields))
}
#[cfg(feature = "encryption")]
async fn build_exec_for_file(
&self,
state: &dyn Session,
data_file: &DataFileChange,
layout: Option<&ParquetFileLayout>,
proj_info: &ProjectionInfo,
encryption_factory: &Option<Arc<dyn EncryptionFactory>>,
) -> DataFusionResult<Arc<dyn ExecutionPlan>> {
let read_schema = self.file_read_schema(layout);
let parquet_source = if let Some(factory) = encryption_factory {
ParquetSource::new(read_schema).with_encryption_factory(Arc::clone(factory))
} else {
ParquetSource::new(read_schema)
};
self.build_exec_for_file_impl(state, data_file, layout, proj_info, parquet_source)
.await
}
#[cfg(not(feature = "encryption"))]
async fn build_exec_for_file(
&self,
state: &dyn Session,
data_file: &DataFileChange,
layout: Option<&ParquetFileLayout>,
proj_info: &ProjectionInfo,
) -> DataFusionResult<Arc<dyn ExecutionPlan>> {
let parquet_source = ParquetSource::new(self.file_read_schema(layout));
self.build_exec_for_file_impl(state, data_file, layout, proj_info, parquet_source)
.await
}
fn file_read_schema(&self, layout: Option<&ParquetFileLayout>) -> SchemaRef {
match layout {
Some(layout) => Arc::clone(&layout.read_schema),
None => Arc::clone(&self.table_schema),
}
}
async fn build_exec_for_file_impl(
&self,
_state: &dyn Session,
data_file: &DataFileChange,
layout: Option<&ParquetFileLayout>,
proj_info: &ProjectionInfo,
parquet_source: ParquetSource,
) -> DataFusionResult<Arc<dyn ExecutionPlan>> {
let resolved_path = resolve_path(
&self.table_path,
&data_file.path,
data_file.path_is_relative,
)
.map_err(|e| DataFusionError::External(Box::new(e)))?;
let mut pf = PartitionedFile::new(
&resolved_path,
validated_file_size(data_file.file_size_bytes, &resolved_path)?,
);
if let Some(footer_size) = data_file.footer_size
&& footer_size > 0
&& let Ok(hint) = usize::try_from(footer_size)
{
pf = pf.with_metadata_size_hint(hint);
}
let parquet_projection = if proj_info.table_indices.is_empty() {
Some(vec![row_count_probe_index(&self.file_read_schema(layout))])
} else {
Some(proj_info.table_indices.clone())
};
let mut builder = FileScanConfigBuilder::new(
self.object_store_url.as_ref().clone(),
Arc::new(parquet_source),
)
.with_file_group(FileGroup::new(vec![pf]));
if let Some(proj) = parquet_projection {
builder = builder.with_projection_indices(Some(proj))?;
}
let file_scan_config = builder.build();
let mut parquet_exec: Arc<dyn ExecutionPlan> =
DataSourceExec::from_data_source(file_scan_config);
let skip_input_columns = proj_info.table_indices.is_empty();
if !skip_input_columns {
let table_fields: Vec<FieldRef> = proj_info
.table_indices
.iter()
.map(|&i| Arc::clone(&self.table_schema.fields()[i]))
.collect();
let name_mapping = layout.map(|l| l.name_mapping.clone()).unwrap_or_default();
parquet_exec = present_catalog_schema(parquet_exec, &table_fields, &name_mapping);
}
let cdc_exec_schema = if skip_input_columns {
let mut fields = Vec::new();
if proj_info.need_snapshot_id {
fields.push(Field::new("snapshot_id", DataType::Int64, false));
}
if proj_info.need_rowid {
fields.push(Field::new("rowid", DataType::Int64, true));
}
if proj_info.need_change_type {
fields.push(Field::new("change_type", DataType::Utf8, false));
}
Arc::new(Schema::new(fields))
} else {
self.build_cdc_exec_schema(
&proj_info.table_indices,
proj_info.need_rowid,
proj_info.need_snapshot_id,
proj_info.need_change_type,
)
};
Ok(Arc::new(PrependCDCColumnsExec::new(
parquet_exec,
data_file.begin_snapshot,
ChangeType::Insert,
proj_info.need_rowid,
proj_info.need_snapshot_id,
proj_info.need_change_type,
skip_input_columns,
cdc_exec_schema,
)))
}
async fn detect_delete_file_snapshot_name(
&self,
state: &dyn Session,
path: &str,
is_relative: bool,
) -> DataFusionResult<Option<String>> {
let resolved = resolve_path(&self.table_path, path, is_relative)
.map_err(|e| DataFusionError::External(Box::new(e)))?;
let facts =
read_parquet_footer_facts(state, self.object_store_url.as_ref(), &resolved, None)
.await?;
Ok(facts.field_ids.get(&SNAPSHOT_ID_PARQUET_FIELD_ID).cloned())
}
fn read_schema_with_embedded(
&self,
layout: &ParquetFileLayout,
rowid_name: &Option<String>,
snapshot_name: &Option<String>,
) -> SchemaRef {
match (rowid_name, snapshot_name) {
(None, None) => Arc::clone(&layout.read_schema),
(rowid, snapshot) => {
let mut fields: Vec<FieldRef> =
layout.read_schema.fields().iter().cloned().collect();
if let Some(name) = rowid {
fields.push(Arc::new(Field::new(name, DataType::Int64, true)));
}
if let Some(name) = snapshot {
fields.push(Arc::new(Field::new(name, DataType::Int64, true)));
}
Arc::new(Schema::new(fields))
},
}
}
fn catalog_table_fields(&self) -> Vec<FieldRef> {
self.table_schema.fields().iter().cloned().collect()
}
fn build_insert_scan(
&self,
data_file: &DataFileChange,
layout: &ParquetFileLayout,
need_rowid_resolution: bool,
) -> DataFusionResult<Arc<dyn ExecutionPlan>> {
let resolved = resolve_path(
&self.table_path,
&data_file.path,
data_file.path_is_relative,
)
.map_err(|e| DataFusionError::External(Box::new(e)))?;
let mut pf = PartitionedFile::new(
&resolved,
validated_file_size(data_file.file_size_bytes, &resolved)?,
);
if let Some(footer) = data_file.footer_size
&& footer > 0
&& let Ok(hint) = usize::try_from(footer)
{
pf = pf.with_metadata_size_hint(hint);
}
let snapshot_name = if data_file.partial_max.is_some() {
layout.embedded_snapshot_parquet_name.clone()
} else {
None
};
let embedded_rowid = &layout.embedded_rowid_parquet_name;
let read_schema = self.read_schema_with_embedded(layout, embedded_rowid, &snapshot_name);
let plain_scan = |pf: PartitionedFile, schema: SchemaRef| {
let builder = FileScanConfigBuilder::new(
self.object_store_url.as_ref().clone(),
Arc::new(ParquetSource::new(schema)),
)
.with_file_group(FileGroup::new(vec![pf]));
DataSourceExec::from_data_source(builder.build())
};
let scan: Arc<dyn ExecutionPlan> = match embedded_rowid {
Some(_) => plain_scan(pf, read_schema),
None if need_rowid_resolution => {
let source = PositionalFileSource::wrap(Arc::new(ParquetSource::new(read_schema)));
let builder =
FileScanConfigBuilder::new(self.object_store_url.as_ref().clone(), source)
.with_file_group(FileGroup::new(vec![pf]))
.with_partitioned_by_file_group(true);
let scan = DataSourceExec::from_data_source(builder.build());
Arc::new(FileRowNumberExec::new(scan, vec![0]))
},
None => plain_scan(pf, read_schema),
};
Ok(present_catalog_schema(
scan,
&self.catalog_table_fields(),
&layout.name_mapping,
))
}
fn build_delete_data_scan(
&self,
resolved_path: &str,
size_bytes: i64,
footer_size: i64,
layout: &ParquetFileLayout,
embedded_name: &Option<String>,
) -> DataFusionResult<Arc<dyn ExecutionPlan>> {
let mut pf = PartitionedFile::new(
resolved_path,
validated_file_size(size_bytes, resolved_path)?,
);
if footer_size > 0
&& let Ok(hint) = usize::try_from(footer_size)
{
pf = pf.with_metadata_size_hint(hint);
}
let read_schema = self.read_schema_with_embedded(layout, embedded_name, &None);
let source = PositionalFileSource::wrap(Arc::new(ParquetSource::new(read_schema)));
let builder = FileScanConfigBuilder::new(self.object_store_url.as_ref().clone(), source)
.with_file_group(FileGroup::new(vec![pf]))
.with_partitioned_by_file_group(true);
let scan = DataSourceExec::from_data_source(builder.build());
Ok(present_catalog_schema(
Arc::new(FileRowNumberExec::new(scan, vec![0])),
&self.catalog_table_fields(),
&layout.name_mapping,
))
}
fn build_delete_file_scan(
&self,
path: &str,
is_relative: bool,
size_bytes: i64,
footer_size: i64,
snapshot_name: &Option<String>,
) -> DataFusionResult<Arc<dyn ExecutionPlan>> {
let resolved = resolve_path(&self.table_path, path, is_relative)
.map_err(|e| DataFusionError::External(Box::new(e)))?;
let mut pf = PartitionedFile::new(&resolved, validated_file_size(size_bytes, &resolved)?);
if footer_size > 0
&& let Ok(hint) = usize::try_from(footer_size)
{
pf = pf.with_metadata_size_hint(hint);
}
let schema = match snapshot_name {
Some(name) => {
let mut fields: Vec<Field> = delete_file_schema()
.fields()
.iter()
.map(|f| f.as_ref().clone())
.collect();
fields.push(Field::new(name, DataType::Int64, true));
Arc::new(Schema::new(fields))
},
None => delete_file_schema(),
};
let builder = FileScanConfigBuilder::new(
self.object_store_url.as_ref().clone(),
Arc::new(ParquetSource::new(schema)),
)
.with_file_group(FileGroup::new(vec![pf]));
Ok(DataSourceExec::from_data_source(builder.build()))
}
#[cfg(feature = "encryption")]
fn reject_evolved_encrypted_table(
&self,
columns: &[DuckLakeTableColumn],
data_files: &[DataFileChange],
) -> DataFusionResult<()> {
let Some(oldest) = data_files.iter().map(|f| f.begin_snapshot).min() else {
return Ok(());
};
if oldest >= self.end_snapshot {
return Ok(());
}
let then = self
.provider
.get_table_structure(self.table_id, oldest)
.map_err(|e| DataFusionError::External(Box::new(e)))?;
let identity = |column: &DuckLakeTableColumn| -> DataFusionResult<(String, String)> {
let data_type = column
.data_type()
.map_err(|e| DataFusionError::External(Box::new(e)))?;
Ok((column.column_name.clone(), format!("{data_type:?}")))
};
let mut then_by_id: HashMap<i64, (String, String)> = HashMap::new();
let mut then_names: HashSet<String> = HashSet::new();
for column in &then {
then_by_id.insert(column.column_id, identity(column)?);
then_names.insert(column.column_name.clone());
}
let mut evolved = false;
for column in columns {
evolved = match then_by_id.get(&column.column_id) {
Some(previous) => *previous != identity(column)?,
None => then_names.contains(&column.column_name),
};
if evolved {
break;
}
}
if evolved {
return Err(DataFusionError::External(
format!(
"table {} has encrypted data files and its columns changed between \
snapshot {oldest} and snapshot {}: a column was renamed, or dropped and \
re-added under the same name. Change feeds resolve columns by field id, \
which requires reading each file's parquet footer, and an encrypted \
footer cannot be read here — so the feed would return another column's \
values. Query a snapshot window whose files all predate the change, or \
read the table directly.",
self.table_id, self.end_snapshot
)
.into(),
));
}
Ok(())
}
async fn build_correlated_changes(
&self,
state: &dyn Session,
columns: &[DuckLakeTableColumn],
data_files: &[DataFileChange],
delete_files: &[crate::metadata_provider::DeleteFileChange],
layouts: &[Arc<ParquetFileLayout>],
projection: Option<&Vec<usize>>,
) -> DataFusionResult<Arc<dyn ExecutionPlan>> {
let table_len = self.table_schema.fields().len();
check_column_count(table_len, columns.len())?;
let need_rowid = projection.is_none_or(|idx| idx.contains(&ROWID_IDX));
let mut insert_units = Vec::with_capacity(data_files.len());
for (df, layout) in data_files.iter().zip(layouts.iter()) {
let is_partial = df.partial_max.is_some();
if is_partial && layout.embedded_snapshot_parquet_name.is_none() {
return Err(DataFusionError::External(
format!(
"data file {} is a merged partial file (partial_max set) but carries \
no embedded per-row snapshot column; cannot attribute its rows to \
snapshots",
df.path
)
.into(),
));
}
let resolve_rowid = need_rowid || is_partial;
let has_embedded_rowid = layout.embedded_rowid_parquet_name.is_some();
let mut next_idx = table_len;
let embedded_col_idx = has_embedded_rowid.then(|| {
let i = next_idx;
next_idx += 1;
i
});
let snapshot_col_idx = (is_partial && layout.embedded_snapshot_parquet_name.is_some())
.then(|| {
let i = next_idx;
next_idx += 1;
i
});
let pos_col_idx = (!has_embedded_rowid && resolve_rowid).then_some(next_idx);
insert_units.push(InsertUnit {
snapshot_id: df.begin_snapshot,
scan: self.build_insert_scan(df, layout, resolve_rowid)?,
embedded_col_idx,
snapshot_col_idx,
pos_col_idx,
row_id_start: df.row_id_start,
});
}
let delete_units = {
let mut delete_units = Vec::with_capacity(delete_files.len());
for dfc in delete_files {
validated_record_count(dfc.data_record_count, &dfc.data_file_path)?;
let resolved = resolve_path(
&self.table_path,
&dfc.data_file_path,
dfc.data_file_path_is_relative,
)
.map_err(|e| DataFusionError::External(Box::new(e)))?;
let source_layout = self
.file_layout(
state,
columns,
&dfc.data_file_path,
dfc.data_file_path_is_relative,
)
.await?;
let old_embedded = source_layout.embedded_rowid_parquet_name.clone();
let data_scan = self.build_delete_data_scan(
&resolved,
dfc.data_file_size_bytes,
dfc.data_file_footer_size.unwrap_or(0),
&source_layout,
&old_embedded,
)?;
let snapshot_name = match &dfc.current_delete_path {
Some(p) => {
self.detect_delete_file_snapshot_name(
state,
p,
dfc.current_delete_path_is_relative.unwrap_or(true),
)
.await?
},
None => None,
};
if snapshot_name.is_none() && dfc.snapshot_id < self.start_snapshot {
return Err(DataFusionError::External(
format!(
"delete file {:?} begins before the query window but carries no \
embedded per-row snapshot column; its deletions cannot be attributed",
dfc.current_delete_path
)
.into(),
));
}
let cumulative = snapshot_name.is_some();
let current_delete_scan = match &dfc.current_delete_path {
Some(p) => Some(self.build_delete_file_scan(
p,
dfc.current_delete_path_is_relative.unwrap_or(true),
dfc.current_delete_file_size_bytes.unwrap_or(0),
dfc.current_delete_footer_size.unwrap_or(0),
&snapshot_name,
)?),
None => None,
};
let previous_delete_scan = match &dfc.previous_delete_path {
Some(p) if !cumulative => Some(self.build_delete_file_scan(
p,
dfc.previous_delete_path_is_relative.unwrap_or(true),
dfc.previous_delete_file_size_bytes.unwrap_or(0),
dfc.previous_delete_footer_size.unwrap_or(0),
&None,
)?),
_ => None,
};
delete_units.push(DeleteUnit {
snapshot_id: dfc.snapshot_id,
data_scan,
embedded_col_idx: old_embedded.as_ref().map(|_| table_len),
current_delete_scan,
previous_delete_scan,
cumulative,
record_count: dfc.data_record_count,
row_id_start: dfc.data_row_id_start,
});
}
delete_units
};
let full: Arc<dyn ExecutionPlan> = Arc::new(TableChangesExec::new(
insert_units,
delete_units,
self.table_schema.clone(),
self.output_schema.clone(),
table_len,
need_rowid,
(self.start_snapshot, self.end_snapshot),
));
match projection {
None => Ok(full),
Some(indices) => {
let exprs: Vec<(Arc<dyn PhysicalExpr>, String)> = indices
.iter()
.map(|&i| {
let f = self.output_schema.field(i);
(
Arc::new(Column::new(f.name(), i)) as Arc<dyn PhysicalExpr>,
f.name().to_string(),
)
})
.collect();
Ok(Arc::new(ProjectionExec::try_new(exprs, full)?))
},
}
}
}
#[async_trait]
impl TableProvider for TableChangesTable {
fn schema(&self) -> SchemaRef {
self.output_schema.clone()
}
fn table_type(&self) -> TableType {
TableType::View
}
async fn scan(
&self,
state: &dyn Session,
projection: Option<&Vec<usize>>,
_filters: &[datafusion::prelude::Expr],
_limit: Option<usize>,
) -> DataFusionResult<Arc<dyn ExecutionPlan>> {
let proj_info = self.analyze_projection(projection);
let columns = self.resolve_columns()?;
let data_files = self
.provider
.get_data_files_added_between_snapshots(
self.table_id,
self.start_snapshot,
self.end_snapshot,
)
.map_err(|e| DataFusionError::External(Box::new(e)))?;
let delete_files = if self.insertions_only {
Vec::new()
} else {
self.provider
.get_delete_files_added_between_snapshots(
self.table_id,
self.start_snapshot,
self.end_snapshot,
)
.map_err(|e| DataFusionError::External(Box::new(e)))?
};
if data_files.is_empty() && delete_files.is_empty() {
use datafusion::physical_plan::empty::EmptyExec;
return Ok(Arc::new(EmptyExec::new(proj_info.output_schema)));
}
let mut data_files = data_files;
let any_encrypted = {
#[cfg(feature = "encryption")]
{
data_files.iter().any(|d| d.encryption_key.is_some())
}
#[cfg(not(feature = "encryption"))]
{
false
}
};
if any_encrypted {
data_files.retain(|f| f.begin_snapshot >= self.start_snapshot);
if data_files.is_empty() {
use datafusion::physical_plan::empty::EmptyExec;
return Ok(Arc::new(EmptyExec::new(proj_info.output_schema)));
}
#[cfg(feature = "encryption")]
self.reject_evolved_encrypted_table(&columns, &data_files)?;
}
let any_partial = data_files.iter().any(|f| f.partial_max.is_some());
if (proj_info.need_rowid || !delete_files.is_empty() || any_partial) && !any_encrypted {
let mut layouts: Vec<Arc<ParquetFileLayout>> = Vec::with_capacity(data_files.len());
for data_file in &data_files {
layouts.push(
self.file_layout(state, &columns, &data_file.path, data_file.path_is_relative)
.await?,
);
}
return self
.build_correlated_changes(
state,
&columns,
&data_files,
&delete_files,
&layouts,
projection,
)
.await;
}
#[cfg(feature = "encryption")]
let encryption_factory: Option<Arc<dyn EncryptionFactory>> = {
let mut builder = EncryptionFactoryBuilder::new();
for data_file in &data_files {
let resolved_path = resolve_path(
&self.table_path,
&data_file.path,
data_file.path_is_relative,
)
.map_err(|e| DataFusionError::External(Box::new(e)))?;
builder.add_file(&resolved_path, data_file.encryption_key.as_deref());
}
let factory = builder.build();
if factory.has_encrypted_files() {
Some(Arc::new(factory) as Arc<dyn EncryptionFactory>)
} else {
None
}
};
let mut execs: Vec<Arc<dyn ExecutionPlan>> = Vec::with_capacity(data_files.len());
for data_file in &data_files {
let layout = if any_encrypted {
None
} else {
Some(
self.file_layout(state, &columns, &data_file.path, data_file.path_is_relative)
.await?,
)
};
#[cfg(feature = "encryption")]
let exec = self
.build_exec_for_file(
state,
data_file,
layout.as_deref(),
&proj_info,
&encryption_factory,
)
.await?;
#[cfg(not(feature = "encryption"))]
let exec = self
.build_exec_for_file(state, data_file, layout.as_deref(), &proj_info)
.await?;
execs.push(exec);
}
if execs.len() == 1 {
Ok(execs.into_iter().next().unwrap())
} else {
UnionExec::try_new(execs)
}
}
}
#[derive(Clone)]
struct InsertUnit {
snapshot_id: i64,
scan: Arc<dyn ExecutionPlan>,
embedded_col_idx: Option<usize>,
snapshot_col_idx: Option<usize>,
pos_col_idx: Option<usize>,
row_id_start: Option<i64>,
}
#[derive(Clone)]
struct DeleteUnit {
snapshot_id: i64,
data_scan: Arc<dyn ExecutionPlan>,
embedded_col_idx: Option<usize>,
current_delete_scan: Option<Arc<dyn ExecutionPlan>>,
previous_delete_scan: Option<Arc<dyn ExecutionPlan>>,
cumulative: bool,
record_count: i64,
row_id_start: Option<i64>,
}
struct KeyedRows {
snapshot_id: i64,
table_batch: RecordBatch,
rowid: Int64Array,
}
#[derive(Debug)]
pub struct TableChangesExec {
insert_units: Vec<InsertUnit>,
delete_units: Vec<DeleteUnit>,
#[allow(dead_code)]
table_schema: SchemaRef,
output_schema: SchemaRef,
table_len: usize,
need_rowid: bool,
window: (i64, i64),
properties: Arc<PlanProperties>,
}
impl std::fmt::Debug for InsertUnit {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("InsertUnit")
.field("snapshot_id", &self.snapshot_id)
.field("embedded_col_idx", &self.embedded_col_idx)
.finish_non_exhaustive()
}
}
impl std::fmt::Debug for DeleteUnit {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DeleteUnit")
.field("snapshot_id", &self.snapshot_id)
.field("embedded_col_idx", &self.embedded_col_idx)
.finish_non_exhaustive()
}
}
impl TableChangesExec {
#[allow(clippy::too_many_arguments)]
fn new(
insert_units: Vec<InsertUnit>,
delete_units: Vec<DeleteUnit>,
table_schema: SchemaRef,
output_schema: SchemaRef,
table_len: usize,
need_rowid: bool,
window: (i64, i64),
) -> Self {
let properties = Arc::new(PlanProperties::new(
EquivalenceProperties::new(output_schema.clone()),
datafusion::physical_expr::Partitioning::UnknownPartitioning(1),
datafusion::physical_plan::execution_plan::EmissionType::Final,
datafusion::physical_plan::execution_plan::Boundedness::Bounded,
));
Self {
insert_units,
delete_units,
table_schema,
output_schema,
table_len,
need_rowid,
window,
properties,
}
}
}
impl DisplayAs for TableChangesExec {
fn fmt_as(&self, t: DisplayFormatType, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match t {
DisplayFormatType::Default
| DisplayFormatType::Verbose
| DisplayFormatType::TreeRender => {
write!(
f,
"TableChangesExec: inserts={}, deletes={}",
self.insert_units.len(),
self.delete_units.len()
)
},
}
}
}
impl ExecutionPlan for TableChangesExec {
fn name(&self) -> &str {
"TableChangesExec"
}
fn properties(&self) -> &Arc<PlanProperties> {
&self.properties
}
fn children(&self) -> Vec<&Arc<dyn ExecutionPlan>> {
vec![]
}
fn with_new_children(
self: Arc<Self>,
children: Vec<Arc<dyn ExecutionPlan>>,
) -> DataFusionResult<Arc<dyn ExecutionPlan>> {
if !children.is_empty() {
return Err(DataFusionError::Internal(
"TableChangesExec has no children".to_string(),
));
}
Ok(self)
}
fn schema(&self) -> SchemaRef {
self.output_schema.clone()
}
fn execute(
&self,
partition: usize,
context: Arc<TaskContext>,
) -> DataFusionResult<SendableRecordBatchStream> {
if partition != 0 {
return Err(DataFusionError::Internal(format!(
"TableChangesExec only supports partition 0, got {partition}"
)));
}
let insert_units = self.insert_units.clone();
let delete_units = self.delete_units.clone();
let output_schema = self.output_schema.clone();
let table_len = self.table_len;
let need_rowid = self.need_rowid;
let window = self.window;
let fut = async move {
correlate_changes(
insert_units,
delete_units,
output_schema,
table_len,
need_rowid,
window,
context,
)
.await
};
let schema = self.output_schema.clone();
let stream = futures::stream::once(fut)
.map(|res: DataFusionResult<Vec<RecordBatch>>| match res {
Ok(batches) => futures::stream::iter(batches.into_iter().map(Ok)).boxed(),
Err(e) => futures::stream::iter(std::iter::once(Err(e))).boxed(),
})
.flatten();
Ok(Box::pin(
datafusion::physical_plan::stream::RecordBatchStreamAdapter::new(schema, stream),
))
}
}
#[allow(clippy::too_many_arguments)]
async fn correlate_changes(
insert_units: Vec<InsertUnit>,
delete_units: Vec<DeleteUnit>,
output_schema: SchemaRef,
table_len: usize,
need_rowid: bool,
window: (i64, i64),
context: Arc<TaskContext>,
) -> DataFusionResult<Vec<RecordBatch>> {
let mut postimages: Vec<KeyedRows> = Vec::new();
let mut plain_inserts: Vec<KeyedRows> = Vec::new();
for unit in &insert_units {
let batches =
datafusion::physical_plan::collect(Arc::clone(&unit.scan), context.clone()).await?;
for b in batches {
let n = b.num_rows();
if n == 0 {
continue;
}
let table_batch = b.project(&(0..table_len).collect::<Vec<_>>())?;
let embedded_rowid = match unit.embedded_col_idx {
Some(idx) => Some(int64_column(&b, idx, "embedded rowid")?.clone()),
None => None,
};
let rowid: Int64Array = match (&embedded_rowid, unit.pos_col_idx) {
(Some(arr), _) => (*arr).clone(),
(None, Some(pos_idx)) => {
let row_id_start = unit.row_id_start.ok_or_else(|| {
DataFusionError::Internal(
"cannot synthesize rowid: inserted file has neither an embedded \
rowid nor a row_id_start"
.to_string(),
)
})?;
let pos = int64_column(&b, pos_idx, ROW_POS_COLUMN_NAME)?;
Int64Array::from(
(0..n)
.map(|i| row_id_start + pos.value(i))
.collect::<Vec<i64>>(),
)
},
(None, None) => Int64Array::from(vec![0i64; n]),
};
match unit.snapshot_col_idx {
Some(snap_idx) => {
let snaps = int64_column(&b, snap_idx, "embedded snapshot_id")?;
let mut by_snapshot: std::collections::BTreeMap<i64, Vec<u32>> =
std::collections::BTreeMap::new();
for i in 0..n {
if snaps.is_null(i) {
return Err(DataFusionError::Internal(
"embedded snapshot_id column contains NULL".to_string(),
));
}
let s = snaps.value(i);
if s >= window.0 && s <= window.1 {
by_snapshot.entry(s).or_default().push(i as u32);
}
}
for (snapshot, row_indices) in by_snapshot {
let indices = UInt32Array::from(row_indices);
let cols: Vec<ArrayRef> = table_batch
.columns()
.iter()
.map(|c| {
take(c.as_ref(), &indices, None)
.map_err(|e| DataFusionError::ArrowError(Box::new(e), None))
})
.collect::<DataFusionResult<_>>()?;
let group_batch = RecordBatch::try_new(table_batch.schema(), cols)?;
let group_rowid = take(&rowid, &indices, None)
.map_err(|e| DataFusionError::ArrowError(Box::new(e), None))?
.as_any()
.downcast_ref::<Int64Array>()
.expect("take preserves Int64")
.clone();
postimages.push(KeyedRows {
snapshot_id: snapshot,
table_batch: group_batch,
rowid: group_rowid,
});
}
},
None => {
let keyed = KeyedRows {
snapshot_id: unit.snapshot_id,
table_batch,
rowid,
};
if embedded_rowid.is_some() {
postimages.push(keyed);
} else {
plain_inserts.push(keyed);
}
},
}
}
}
let preimage_rowids_required = need_rowid || !postimages.is_empty();
let mut preimages: Vec<KeyedRows> = Vec::new();
for unit in &delete_units {
let (current, position_snapshots): (Option<HashSet<i64>>, HashMap<i64, i64>) =
if unit.cumulative {
let (set, map) = collect_windowed_delete_positions(
&unit.current_delete_scan,
window,
context.clone(),
)
.await?;
(Some(set), map)
} else {
(
collect_delete_positions(&unit.current_delete_scan, context.clone()).await?,
HashMap::new(),
)
};
let current: HashSet<i64> = match current {
Some(set) => set,
None => (0..unit.record_count).collect(),
};
let previous = collect_delete_positions(&unit.previous_delete_scan, context.clone())
.await?
.unwrap_or_default();
let data_batches =
datafusion::physical_plan::collect(Arc::clone(&unit.data_scan), context.clone())
.await?;
for b in data_batches {
let n = b.num_rows();
if n == 0 {
continue;
}
let pos_idx = b.schema().index_of(ROW_POS_COLUMN_NAME)?;
let pos = b
.column(pos_idx)
.as_any()
.downcast_ref::<Int64Array>()
.ok_or_else(|| {
DataFusionError::Internal(format!("{ROW_POS_COLUMN_NAME} column is not Int64"))
})?;
let embedded = match unit.embedded_col_idx {
Some(idx) => Some(
b.column(idx)
.as_any()
.downcast_ref::<Int64Array>()
.ok_or_else(|| {
DataFusionError::Internal(
"embedded rowid column is not Int64".to_string(),
)
})?,
),
None => None,
};
let synth_start: Option<i64> = if embedded.is_none() && preimage_rowids_required {
Some(unit.row_id_start.ok_or_else(|| {
DataFusionError::Internal(
"cannot synthesize deleted rowid: source file has neither an embedded \
rowid nor a row_id_start"
.to_string(),
)
})?)
} else {
None
};
let mut by_snapshot: std::collections::BTreeMap<i64, (Vec<u32>, Vec<i64>)> =
std::collections::BTreeMap::new();
for i in 0..n {
let p = pos.value(i);
if current.contains(&p) && !previous.contains(&p) {
let rowid = match (embedded, synth_start) {
(Some(arr), _) => arr.value(i),
(None, Some(start)) => start + p,
(None, None) => 0,
};
let snapshot = if unit.cumulative {
*position_snapshots.get(&p).unwrap_or(&unit.snapshot_id)
} else {
unit.snapshot_id
};
let entry = by_snapshot.entry(snapshot).or_default();
entry.0.push(i as u32);
entry.1.push(rowid);
}
}
for (snapshot, (keep, rowids)) in by_snapshot {
let indices = UInt32Array::from(keep);
let table_cols: Vec<ArrayRef> = (0..table_len)
.map(|c| {
take(b.column(c).as_ref(), &indices, None)
.map_err(|e| DataFusionError::ArrowError(Box::new(e), None))
})
.collect::<DataFusionResult<_>>()?;
let table_batch = RecordBatch::try_new(
Arc::new(Schema::new(
(0..table_len)
.map(|c| b.schema().field(c).clone())
.collect::<Vec<_>>(),
)),
table_cols,
)?;
preimages.push(KeyedRows {
snapshot_id: snapshot,
table_batch,
rowid: Int64Array::from(rowids),
});
}
}
}
let post_keys: HashSet<(i64, i64)> = postimages
.iter()
.flat_map(|k| (0..k.rowid.len()).map(move |i| (k.snapshot_id, k.rowid.value(i))))
.collect();
let update_keys: HashSet<(i64, i64)> = preimages
.iter()
.flat_map(|k| (0..k.rowid.len()).map(move |i| (k.snapshot_id, k.rowid.value(i))))
.filter(|key| post_keys.contains(key))
.collect();
let mut out: Vec<RecordBatch> = Vec::new();
for k in &plain_inserts {
out.push(prepend_cdc_columns(
&k.table_batch,
k.rowid.clone(),
k.snapshot_id,
ChangeType::Insert,
&output_schema,
)?);
}
for k in &postimages {
if let Some(b) = filter_and_tag(
k,
&key_mask(k, &update_keys, true),
ChangeType::UpdatePostimage,
&output_schema,
)? {
out.push(b);
}
if let Some(b) = filter_and_tag(
k,
&key_mask(k, &update_keys, false),
ChangeType::Insert,
&output_schema,
)? {
out.push(b);
}
}
for k in &preimages {
if let Some(b) = filter_and_tag(
k,
&key_mask(k, &update_keys, true),
ChangeType::UpdatePreimage,
&output_schema,
)? {
out.push(b);
}
if let Some(b) = filter_and_tag(
k,
&key_mask(k, &update_keys, false),
ChangeType::Delete,
&output_schema,
)? {
out.push(b);
}
}
Ok(out)
}
fn int64_column<'a>(
batch: &'a RecordBatch,
idx: usize,
what: &str,
) -> DataFusionResult<&'a Int64Array> {
batch
.column(idx)
.as_any()
.downcast_ref::<Int64Array>()
.ok_or_else(|| DataFusionError::Internal(format!("{what} column is not Int64")))
}
async fn collect_windowed_delete_positions(
scan: &Option<Arc<dyn ExecutionPlan>>,
window: (i64, i64),
context: Arc<TaskContext>,
) -> DataFusionResult<(HashSet<i64>, HashMap<i64, i64>)> {
let Some(scan) = scan else {
return Ok((HashSet::new(), HashMap::new()));
};
let batches = datafusion::physical_plan::collect(Arc::clone(scan), context).await?;
let mut set = HashSet::new();
let mut map = HashMap::new();
for b in &batches {
if b.num_columns() < 3 {
return Err(DataFusionError::Internal(
"cumulative delete file batch is missing its snapshot column".to_string(),
));
}
let pos = int64_column(b, 1, "delete `pos`")?;
let snaps = int64_column(b, 2, "delete snapshot")?;
for i in 0..pos.len() {
if pos.is_null(i) {
continue;
}
if snaps.is_null(i) {
return Err(DataFusionError::Internal(
"cumulative delete file has a NULL per-row snapshot".to_string(),
));
}
let s = snaps.value(i);
if s >= window.0 && s <= window.1 {
let p = pos.value(i);
set.insert(p);
map.insert(p, s);
}
}
}
Ok((set, map))
}
async fn collect_delete_positions(
scan: &Option<Arc<dyn ExecutionPlan>>,
context: Arc<TaskContext>,
) -> DataFusionResult<Option<HashSet<i64>>> {
let Some(scan) = scan else {
return Ok(None);
};
let batches = datafusion::physical_plan::collect(Arc::clone(scan), context).await?;
let mut set = HashSet::new();
for b in &batches {
if b.num_columns() < 2 {
continue;
}
let pos = b
.column(1)
.as_any()
.downcast_ref::<Int64Array>()
.ok_or_else(|| {
DataFusionError::Internal("delete `pos` column is not Int64".to_string())
})?;
for i in 0..pos.len() {
if !pos.is_null(i) {
set.insert(pos.value(i));
}
}
}
Ok(Some(set))
}
fn prepend_cdc_columns(
table_batch: &RecordBatch,
rowid: Int64Array,
snapshot_id: i64,
change: ChangeType,
output_schema: &SchemaRef,
) -> DataFusionResult<RecordBatch> {
let n = table_batch.num_rows();
let mut cols: Vec<ArrayRef> = Vec::with_capacity(table_batch.num_columns() + CDC_COLS);
cols.push(Arc::new(Int64Array::from(vec![snapshot_id; n])));
cols.push(Arc::new(rowid));
cols.push(Arc::new(StringArray::from(vec![change.as_str(); n])));
cols.extend(table_batch.columns().iter().cloned());
RecordBatch::try_new(output_schema.clone(), cols)
.map_err(|e| DataFusionError::ArrowError(Box::new(e), None))
}
fn key_mask(
keyed: &KeyedRows,
update_keys: &HashSet<(i64, i64)>,
want_update: bool,
) -> BooleanArray {
BooleanArray::from(
(0..keyed.rowid.len())
.map(|i| {
let is_update = update_keys.contains(&(keyed.snapshot_id, keyed.rowid.value(i)));
is_update == want_update
})
.collect::<Vec<bool>>(),
)
}
fn filter_and_tag(
keyed: &KeyedRows,
mask: &BooleanArray,
change: ChangeType,
output_schema: &SchemaRef,
) -> DataFusionResult<Option<RecordBatch>> {
if mask.true_count() == 0 {
return Ok(None);
}
let cols: Vec<ArrayRef> = keyed
.table_batch
.columns()
.iter()
.map(|c| {
arrow::compute::filter(c.as_ref(), mask)
.map_err(|e| DataFusionError::ArrowError(Box::new(e), None))
})
.collect::<DataFusionResult<_>>()?;
let filtered = RecordBatch::try_new(keyed.table_batch.schema(), cols)?;
let rowid = arrow::compute::filter(&keyed.rowid, mask)
.map_err(|e| DataFusionError::ArrowError(Box::new(e), None))?
.as_any()
.downcast_ref::<Int64Array>()
.ok_or_else(|| DataFusionError::Internal("filtered rowid is not Int64".to_string()))?
.clone();
Ok(Some(prepend_cdc_columns(
&filtered,
rowid,
keyed.snapshot_id,
change,
output_schema,
)?))
}
#[derive(Debug)]
pub struct TableInsertionsTable {
inner: TableChangesTable,
output_schema: SchemaRef,
}
impl TableInsertionsTable {
#[allow(clippy::too_many_arguments)]
pub fn new(
provider: Arc<dyn MetadataProvider>,
table_id: i64,
start_snapshot: i64,
end_snapshot: i64,
object_store_url: Arc<ObjectStoreUrl>,
table_path: String,
table_schema: SchemaRef,
) -> Self {
let mut fields: Vec<Field> = Vec::with_capacity(table_schema.fields().len() + 2);
fields.push(Field::new("snapshot_id", DataType::Int64, false));
fields.push(Field::new("rowid", DataType::Int64, true));
fields.extend(table_schema.fields().iter().map(|f| f.as_ref().clone()));
let output_schema = Arc::new(Schema::new(fields));
let inner = TableChangesTable::new(
provider,
table_id,
start_snapshot,
end_snapshot,
object_store_url,
table_path,
table_schema,
)
.insertions_only();
Self {
inner,
output_schema,
}
}
pub fn with_columns(mut self, columns: Vec<DuckLakeTableColumn>) -> Self {
self.inner = self.inner.with_columns(columns);
self
}
fn inner_index(outer: usize) -> usize {
if outer < CHANGE_TYPE_IDX {
outer
} else {
outer + 1
}
}
}
#[async_trait]
impl TableProvider for TableInsertionsTable {
fn schema(&self) -> SchemaRef {
self.output_schema.clone()
}
fn table_type(&self) -> TableType {
TableType::View
}
async fn scan(
&self,
state: &dyn Session,
projection: Option<&Vec<usize>>,
filters: &[datafusion::prelude::Expr],
limit: Option<usize>,
) -> DataFusionResult<Arc<dyn ExecutionPlan>> {
let translated: Vec<usize> = match projection {
Some(indices) => indices.iter().map(|&i| Self::inner_index(i)).collect(),
None => (0..self.output_schema.fields().len())
.map(Self::inner_index)
.collect(),
};
self.inner
.scan(state, Some(&translated), filters, limit)
.await
}
}