use std::collections::HashSet;
use std::fmt;
use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context, Poll};
use arrow::compute::filter_record_batch;
use arrow_array::{ArrayRef, BooleanArray, RecordBatch};
use arrow_row::{OwnedRow, RowConverter, SortField};
use arrow_schema::SchemaRef;
use datafusion::error::{DataFusionError, Result as DFResult};
use datafusion::execution::TaskContext;
use datafusion::physical_expr::EquivalenceProperties;
use datafusion::physical_plan::{
DisplayAs, DisplayFormatType, ExecutionPlan, ExecutionPlanProperties, PlanProperties,
SendableRecordBatchStream,
};
use futures::{Stream, StreamExt};
#[derive(Debug)]
pub struct FirstByPkExec {
input: Arc<dyn ExecutionPlan>,
pk_columns: Vec<String>,
properties: Arc<PlanProperties>,
}
impl FirstByPkExec {
pub fn new(input: Arc<dyn ExecutionPlan>, pk_columns: Vec<String>) -> Self {
let properties = Arc::new(PlanProperties::new(
EquivalenceProperties::new(input.schema()),
input.output_partitioning().clone(),
input.pipeline_behavior(),
input.boundedness(),
));
Self {
input,
pk_columns,
properties,
}
}
}
impl DisplayAs for FirstByPkExec {
fn fmt_as(&self, t: DisplayFormatType, f: &mut fmt::Formatter) -> fmt::Result {
match t {
DisplayFormatType::Default | DisplayFormatType::Verbose => {
write!(f, "FirstByPk: pk={:?}", self.pk_columns)
}
DisplayFormatType::TreeRender => write!(f, "FirstByPk"),
}
}
}
impl ExecutionPlan for FirstByPkExec {
fn name(&self) -> &str {
"FirstByPkExec"
}
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>>,
) -> DFResult<Arc<dyn ExecutionPlan>> {
let input = children.into_iter().next().ok_or_else(|| {
DataFusionError::Internal("FirstByPkExec requires one child".to_string())
})?;
Ok(Arc::new(Self::new(input, self.pk_columns.clone())))
}
fn execute(
&self,
partition: usize,
context: Arc<TaskContext>,
) -> DFResult<SendableRecordBatchStream> {
let schema = self.schema();
let mut pk_indices = Vec::with_capacity(self.pk_columns.len());
let mut sort_fields = Vec::with_capacity(self.pk_columns.len());
for col in &self.pk_columns {
let (idx, field) = schema.column_with_name(col).ok_or_else(|| {
DataFusionError::Internal(format!("Primary key column '{col}' not found"))
})?;
pk_indices.push(idx);
sort_fields.push(SortField::new(field.data_type().clone()));
}
Ok(Box::pin(FirstByPkStream {
input: self.input.execute(partition, context)?,
converter: RowConverter::new(sort_fields)?,
pk_indices,
schema,
seen: HashSet::new(),
}))
}
}
struct FirstByPkStream {
input: SendableRecordBatchStream,
pk_indices: Vec<usize>,
schema: SchemaRef,
converter: RowConverter,
seen: HashSet<OwnedRow>,
}
impl FirstByPkStream {
fn keep_first(&mut self, batch: &RecordBatch) -> DFResult<RecordBatch> {
if self.pk_indices.is_empty() || batch.num_rows() == 0 {
return Ok(batch.clone());
}
let pk_columns = self
.pk_indices
.iter()
.map(|&idx| batch.column(idx).clone())
.collect::<Vec<ArrayRef>>();
let rows = self.converter.convert_columns(&pk_columns)?;
let mut keep = Vec::with_capacity(batch.num_rows());
for row in 0..batch.num_rows() {
keep.push(self.seen.insert(rows.row(row).owned()));
}
filter_record_batch(batch, &BooleanArray::from(keep)).map_err(DataFusionError::from)
}
}
impl Stream for FirstByPkStream {
type Item = DFResult<RecordBatch>;
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
loop {
match futures::ready!(self.input.poll_next_unpin(cx)) {
Some(Ok(batch)) => {
let filtered = self.keep_first(&batch)?;
if filtered.num_rows() > 0 {
return Poll::Ready(Some(Ok(filtered)));
}
}
other => return Poll::Ready(other),
}
}
}
}
impl datafusion::physical_plan::RecordBatchStream for FirstByPkStream {
fn schema(&self) -> SchemaRef {
self.schema.clone()
}
}
#[cfg(test)]
mod tests {
use super::*;
use arrow_array::{Int32Array, StringArray};
use arrow_schema::{DataType, Field, Schema};
use datafusion::prelude::SessionContext;
use datafusion_physical_plan::test::TestMemoryExec;
use futures::TryStreamExt;
async fn kept_tags(schema: SchemaRef, batches: Vec<RecordBatch>, pk: &[&str]) -> Vec<i32> {
let input = TestMemoryExec::try_new_exec(&[batches], schema, None).unwrap();
let exec = FirstByPkExec::new(input, pk.iter().map(|c| c.to_string()).collect());
let stream = exec.execute(0, SessionContext::new().task_ctx()).unwrap();
let out: Vec<RecordBatch> = stream.try_collect().await.unwrap();
out.iter()
.flat_map(|batch| {
batch
.column_by_name("tag")
.expect("tag is projected")
.as_any()
.downcast_ref::<Int32Array>()
.expect("tag is Int32")
.values()
.to_vec()
})
.collect()
}
fn string_pk_schema() -> SchemaRef {
Arc::new(Schema::new(vec![
Field::new("pk", DataType::Utf8, true),
Field::new("tag", DataType::Int32, false),
]))
}
fn string_pk_batch(schema: &SchemaRef, pks: Vec<Option<&str>>, tags: &[i32]) -> RecordBatch {
RecordBatch::try_new(
schema.clone(),
vec![
Arc::new(StringArray::from(pks)),
Arc::new(Int32Array::from(tags.to_vec())),
],
)
.unwrap()
}
#[tokio::test]
async fn duplicates_are_dropped_across_batch_boundaries() {
let schema = string_pk_schema();
let batches = vec![
string_pk_batch(&schema, vec![Some("a"), Some("b")], &[1, 2]),
string_pk_batch(&schema, vec![Some("a"), Some("c")], &[3, 4]),
];
assert_eq!(
kept_tags(schema, batches, &["pk"]).await,
vec![1, 2, 4],
"the second 'a' must be dropped and the first kept"
);
}
#[tokio::test]
async fn composite_keys_collapse_on_the_whole_tuple() {
let schema = Arc::new(Schema::new(vec![
Field::new("pk_a", DataType::Utf8, true),
Field::new("pk_b", DataType::Int32, true),
Field::new("tag", DataType::Int32, false),
]));
let batch = RecordBatch::try_new(
schema.clone(),
vec![
Arc::new(StringArray::from(vec![Some("x"), Some("x"), Some("x")])),
Arc::new(Int32Array::from(vec![1, 2, 1])),
Arc::new(Int32Array::from(vec![1, 2, 3])),
],
)
.unwrap();
assert_eq!(
kept_tags(schema, vec![batch], &["pk_a", "pk_b"]).await,
vec![1, 2],
"('x', 2) is a distinct key; the repeated ('x', 1) is not"
);
}
#[tokio::test]
async fn null_keys_collapse_and_stay_distinct_from_empty() {
let schema = string_pk_schema();
let batch = string_pk_batch(
&schema,
vec![None, Some(""), None, Some("a")],
&[1, 2, 3, 4],
);
assert_eq!(
kept_tags(schema, vec![batch], &["pk"]).await,
vec![1, 2, 4],
"the repeated null drops; the empty string is its own key"
);
}
}