use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context, Poll};
use arrow::array::{ArrayRef, Int64Array};
use arrow::datatypes::{DataType, Field, FieldRef, Fields, Schema, SchemaRef};
use arrow::record_batch::RecordBatch;
use datafusion::common::config::ConfigOptions;
use datafusion::common::tree_node::{TreeNode, TreeNodeRecursion};
use datafusion::datasource::table_schema::TableSchema;
use datafusion::error::{DataFusionError, Result as DataFusionResult};
use datafusion::execution::{RecordBatchStream, SendableRecordBatchStream, TaskContext};
use datafusion::physical_expr::EquivalenceProperties;
use datafusion::physical_expr::expressions::Column;
use datafusion::physical_expr::{PhysicalExpr, PhysicalSortExpr};
use datafusion::physical_plan::filter_pushdown::{
ChildFilterDescription, FilterDescription, FilterPushdownPhase,
};
use datafusion::physical_plan::sort_pushdown::SortOrderPushdownResult;
use datafusion::physical_plan::{
DisplayAs, DisplayFormatType, ExecutionPlan, ExecutionPlanProperties, PlanProperties,
};
use futures::Stream;
use parquet::arrow::RowNumber;
pub const ROWID_COLUMN_NAME: &str = "rowid";
pub const ROW_NUMBER_EXTENSION_TYPE: &str = "parquet.virtual.row_number";
pub const ROW_POS_COLUMN_NAME: &str = "__ducklake_row_pos";
pub const ROW_ID_PARQUET_FIELD_ID: i32 = 2_147_483_540;
pub const EMBEDDED_ROW_ID_COLUMN_NAME: &str = "_ducklake_internal_row_id";
pub fn embedded_rowid_field() -> Field {
let mut metadata = std::collections::HashMap::new();
metadata.insert(
"PARQUET:field_id".to_string(),
ROW_ID_PARQUET_FIELD_ID.to_string(),
);
Field::new(EMBEDDED_ROW_ID_COLUMN_NAME, DataType::Int64, true).with_metadata(metadata)
}
pub const SNAPSHOT_ID_PARQUET_FIELD_ID: i32 = 2_147_483_539;
pub const EMBEDDED_SNAPSHOT_ID_COLUMN_NAME: &str = "_ducklake_internal_snapshot_id";
pub fn embedded_snapshot_id_field() -> Field {
let mut metadata = std::collections::HashMap::new();
metadata.insert(
"PARQUET:field_id".to_string(),
SNAPSHOT_ID_PARQUET_FIELD_ID.to_string(),
);
Field::new(EMBEDDED_SNAPSHOT_ID_COLUMN_NAME, DataType::Int64, true).with_metadata(metadata)
}
pub fn rowid_field() -> Field {
Field::new(ROWID_COLUMN_NAME, DataType::Int64, true)
}
pub(crate) fn validate_row_pos_field(
node: &str,
index: usize,
field: &FieldRef,
) -> DataFusionResult<()> {
if field.data_type() != &DataType::Int64
|| field.extension_type_name() != Some(ROW_NUMBER_EXTENSION_TYPE)
{
return Err(DataFusionError::Internal(format!(
"{node}: column {index} (`{}`) is not the reader-produced physical-position \
column (expected Int64 carrying `{ROW_NUMBER_EXTENSION_TYPE}`)",
field.name()
)));
}
Ok(())
}
pub(crate) fn row_pos_virtual_field(name: &str) -> FieldRef {
Arc::new(Field::new(name, DataType::Int64, false).with_extension_type(RowNumber))
}
pub(crate) fn unique_row_pos_name<'a>(
read_schema: &Schema,
also_reserved: impl Iterator<Item = &'a str> + Clone,
) -> String {
let taken = |name: &str| {
read_schema.field_with_name(name).is_ok()
|| also_reserved.clone().any(|other| other == name)
};
if !taken(ROW_POS_COLUMN_NAME) {
return ROW_POS_COLUMN_NAME.to_string();
}
let mut suffix = 1;
loop {
let candidate = format!("{ROW_POS_COLUMN_NAME}_{suffix}");
if !taken(&candidate) {
return candidate;
}
suffix += 1;
}
}
pub(crate) fn positional_table_schema(read_schema: SchemaRef) -> (TableSchema, usize, String) {
positional_table_schema_reserving(read_schema, std::iter::empty())
}
pub(crate) fn positional_table_schema_reserving<'a>(
read_schema: SchemaRef,
also_reserved: impl Iterator<Item = &'a str> + Clone,
) -> (TableSchema, usize, String) {
let name = unique_row_pos_name(read_schema.as_ref(), also_reserved);
let pos_index = read_schema.fields().len();
let table_schema = TableSchema::builder(read_schema)
.with_virtual_columns(Fields::from(vec![row_pos_virtual_field(&name)]))
.build();
(table_schema, pos_index, name)
}
#[derive(Debug)]
pub struct RowIdExec {
input: Arc<dyn ExecutionPlan>,
row_id_start: Option<i64>,
pos_index: usize,
schema: SchemaRef,
properties: Arc<PlanProperties>,
}
impl RowIdExec {
pub fn try_new(
input: Arc<dyn ExecutionPlan>,
row_id_start: Option<i64>,
pos_index: usize,
) -> DataFusionResult<Self> {
let input_schema = input.schema();
let field = input_schema.fields().get(pos_index).ok_or_else(|| {
DataFusionError::Internal(format!(
"RowIdExec: position index {pos_index} is out of range for an input of \
{} columns",
input_schema.fields().len()
))
})?;
validate_row_pos_field("RowIdExec", pos_index, field)?;
let mut fields: Vec<Arc<Field>> = input_schema.fields().iter().cloned().collect();
fields.push(Arc::new(rowid_field()));
let schema = Arc::new(Schema::new(fields));
let properties = Arc::new(PlanProperties::new(
EquivalenceProperties::new(schema.clone()),
input.output_partitioning().clone(),
input.pipeline_behavior(),
input.boundedness(),
));
Ok(Self {
input,
row_id_start,
pos_index,
schema,
properties,
})
}
}
impl DisplayAs for RowIdExec {
fn fmt_as(&self, _t: DisplayFormatType, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(
f,
"RowIdExec: row_id_start={}",
self.row_id_start
.map_or_else(|| "NULL".to_string(), |v| v.to_string())
)
}
}
impl ExecutionPlan for RowIdExec {
fn apply_expressions(
&self,
_f: &mut dyn FnMut(&Arc<dyn PhysicalExpr>) -> DataFusionResult<TreeNodeRecursion>,
) -> DataFusionResult<TreeNodeRecursion> {
Ok(TreeNodeRecursion::Continue)
}
fn name(&self) -> &str {
"RowIdExec"
}
fn properties(&self) -> &Arc<PlanProperties> {
&self.properties
}
fn children(&self) -> Vec<&Arc<dyn ExecutionPlan>> {
vec![&self.input]
}
fn maintains_input_order(&self) -> Vec<bool> {
vec![true]
}
fn gather_filters_for_pushdown(
&self,
_phase: FilterPushdownPhase,
parent_filters: Vec<Arc<dyn PhysicalExpr>>,
_config: &ConfigOptions,
) -> DataFusionResult<FilterDescription> {
let allowed = (0..self.input.schema().fields().len()).collect();
let child = ChildFilterDescription::from_child_with_allowed_indices(
&parent_filters,
allowed,
&self.input,
)?;
Ok(FilterDescription::new().with_child(child))
}
fn try_pushdown_sort(
&self,
order: &[PhysicalSortExpr],
) -> DataFusionResult<SortOrderPushdownResult<Arc<dyn ExecutionPlan>>> {
let input_columns = self.input.schema().fields().len();
let touches_rowid = order.iter().any(|sort| {
let mut found = false;
sort.expr
.apply(|expr| {
if let Some(column) = expr.downcast_ref::<Column>()
&& column.index() >= input_columns
{
found = true;
return Ok(TreeNodeRecursion::Stop);
}
Ok(TreeNodeRecursion::Continue)
})
.expect("column scan over a sort expression cannot fail");
found
});
if touches_rowid {
return Ok(SortOrderPushdownResult::Unsupported);
}
self.input.try_pushdown_sort(order)?.into_inexact().try_map(
|inner| -> DataFusionResult<Arc<dyn ExecutionPlan>> {
Ok(Arc::new(RowIdExec::try_new(
inner,
self.row_id_start,
self.pos_index,
)?))
},
)
}
fn with_new_children(
self: Arc<Self>,
children: Vec<Arc<dyn ExecutionPlan>>,
) -> DataFusionResult<Arc<dyn ExecutionPlan>> {
if children.len() != 1 {
return Err(DataFusionError::Internal(
"RowIdExec expects exactly one child".into(),
));
}
Ok(Arc::new(RowIdExec::try_new(
children.into_iter().next().unwrap(),
self.row_id_start,
self.pos_index,
)?))
}
fn execute(
&self,
partition: usize,
context: Arc<TaskContext>,
) -> DataFusionResult<SendableRecordBatchStream> {
Ok(Box::pin(RowIdStream {
input: self.input.execute(partition, context)?,
schema: self.schema.clone(),
row_id_start: self.row_id_start,
pos_index: self.pos_index,
}))
}
}
struct RowIdStream {
input: SendableRecordBatchStream,
schema: SchemaRef,
row_id_start: Option<i64>,
pos_index: usize,
}
impl Stream for RowIdStream {
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 n = batch.num_rows();
let rowid_col: ArrayRef = match self.row_id_start {
Some(start) => {
let pos = match batch
.column(self.pos_index)
.as_any()
.downcast_ref::<Int64Array>()
{
Some(p) => p,
None => {
return Poll::Ready(Some(Err(DataFusionError::Internal(format!(
"`{ROW_POS_COLUMN_NAME}` column is not Int64"
)))));
},
};
let mut builder = Int64Array::builder(n);
for i in 0..n {
builder.append_value(start + pos.value(i));
}
Arc::new(builder.finish())
},
None => {
let mut builder = Int64Array::builder(n);
for _ in 0..n {
builder.append_null();
}
Arc::new(builder.finish())
},
};
let mut cols: Vec<ArrayRef> = batch.columns().to_vec();
cols.push(rowid_col);
let out = RecordBatch::try_new(self.schema.clone(), cols)
.map_err(|e| DataFusionError::ArrowError(Box::new(e), None));
Poll::Ready(Some(out))
},
Poll::Ready(Some(Err(e))) => Poll::Ready(Some(Err(e))),
Poll::Ready(None) => Poll::Ready(None),
Poll::Pending => Poll::Pending,
}
}
}
impl RecordBatchStream for RowIdStream {
fn schema(&self) -> SchemaRef {
self.schema.clone()
}
}
#[cfg(test)]
mod tests {
use super::*;
use arrow::array::{Array, Int32Array};
use datafusion::datasource::memory::MemorySourceConfig;
use futures::StreamExt;
fn batch_with_pos(values: &[i32], positions: &[i64]) -> (SchemaRef, RecordBatch) {
let schema = Arc::new(Schema::new(vec![
Arc::new(Field::new("v", DataType::Int32, false)),
row_pos_virtual_field(ROW_POS_COLUMN_NAME),
]));
let batch = RecordBatch::try_new(
schema.clone(),
vec![
Arc::new(Int32Array::from(values.to_vec())) as ArrayRef,
Arc::new(Int64Array::from(positions.to_vec())) as ArrayRef,
],
)
.unwrap();
(schema, batch)
}
#[tokio::test]
async fn rowid_is_start_plus_position() {
let (schema, batch) = batch_with_pos(&[10, 20, 30], &[5, 6, 9]);
let mem = MemorySourceConfig::try_new_exec(&[vec![batch]], schema, None).unwrap();
let exec = Arc::new(RowIdExec::try_new(mem, Some(1000), 1).unwrap());
assert_eq!(exec.schema().field(2).name(), ROWID_COLUMN_NAME);
let ctx = Arc::new(TaskContext::default());
let mut s = exec.execute(0, ctx).unwrap();
let out = s.next().await.unwrap().unwrap();
let rowids = out
.column(2)
.as_any()
.downcast_ref::<Int64Array>()
.unwrap()
.values()
.to_vec();
assert_eq!(rowids, vec![1005, 1006, 1009]);
assert_eq!(
out.column(1)
.as_any()
.downcast_ref::<Int64Array>()
.unwrap()
.values()
.to_vec(),
vec![5, 6, 9]
);
}
#[tokio::test]
async fn rowid_null_when_start_is_none() {
let (schema, batch) = batch_with_pos(&[1, 2], &[0, 1]);
let mem = MemorySourceConfig::try_new_exec(&[vec![batch]], schema, None).unwrap();
let exec = Arc::new(RowIdExec::try_new(mem, None, 1).unwrap());
let ctx = Arc::new(TaskContext::default());
let mut s = exec.execute(0, ctx).unwrap();
let out = s.next().await.unwrap().unwrap();
let rowid = out.column(2).as_any().downcast_ref::<Int64Array>().unwrap();
assert!(rowid.is_null(0) && rowid.is_null(1));
}
#[test]
fn rowid_errors_when_position_column_is_not_int64() {
let schema = Arc::new(Schema::new(vec![Field::new("v", DataType::Int32, false)]));
let mem = MemorySourceConfig::try_new_exec(&[vec![]], schema, None).unwrap();
assert!(
RowIdExec::try_new(mem, Some(0), 0).is_err(),
"RowIdExec must reject a non-Int64 position column"
);
}
#[test]
fn rowid_rejects_a_plain_int64_column_as_the_position() {
let schema = Arc::new(Schema::new(vec![
Field::new("v", DataType::Int32, false),
Field::new(
crate::row_id::EMBEDDED_ROW_ID_COLUMN_NAME,
DataType::Int64,
true,
),
]));
let mem = MemorySourceConfig::try_new_exec(&[vec![]], schema, None).unwrap();
let err = RowIdExec::try_new(mem, Some(0), 1)
.expect_err("a plain Int64 column must not pass as the position column");
assert!(
err.to_string().contains(ROW_NUMBER_EXTENSION_TYPE),
"the error should name the extension type it wanted: {err}"
);
}
#[test]
fn row_pos_name_avoids_a_user_column_of_the_same_name() {
let plain = Schema::new(vec![Field::new("v", DataType::Int32, false)]);
assert_eq!(
unique_row_pos_name(&plain, std::iter::empty()),
ROW_POS_COLUMN_NAME
);
let clash = Schema::new(vec![
Field::new("v", DataType::Int32, false),
Field::new(ROW_POS_COLUMN_NAME, DataType::Utf8, true),
]);
assert_eq!(
unique_row_pos_name(&clash, std::iter::empty()),
format!("{ROW_POS_COLUMN_NAME}_1")
);
assert_eq!(
unique_row_pos_name(&plain, [ROW_POS_COLUMN_NAME].into_iter()),
format!("{ROW_POS_COLUMN_NAME}_1")
);
let clash_twice = Schema::new(vec![
Field::new(ROW_POS_COLUMN_NAME, DataType::Utf8, true),
Field::new(format!("{ROW_POS_COLUMN_NAME}_1"), DataType::Utf8, true),
]);
assert_eq!(
unique_row_pos_name(&clash_twice, std::iter::empty()),
format!("{ROW_POS_COLUMN_NAME}_2")
);
}
#[test]
fn positional_table_schema_appends_the_virtual_column_last() {
let read_schema = Arc::new(Schema::new(vec![
Field::new("a", DataType::Int32, false),
Field::new("b", DataType::Utf8, true),
]));
let (table_schema, pos_index, name) = positional_table_schema(read_schema);
assert_eq!(pos_index, 2);
assert_eq!(name, ROW_POS_COLUMN_NAME);
let full = table_schema.table_schema();
assert_eq!(full.fields().len(), 3);
assert_eq!(full.field(2).name(), ROW_POS_COLUMN_NAME);
assert_eq!(
full.field(2).extension_type_name(),
Some("parquet.virtual.row_number"),
"the reader recognises the position column by its extension type"
);
}
}