blob: 5bae74433e351545426e251b19e5e27025bc5993 [file]
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
//! Paimon table provider for DataFusion.
use std::any::Any;
use std::sync::Arc;
use async_trait::async_trait;
use datafusion::arrow::datatypes::{Field, Schema, SchemaRef as ArrowSchemaRef};
use datafusion::catalog::Session;
use datafusion::datasource::sink::DataSinkExec;
use datafusion::datasource::{TableProvider, TableType};
use datafusion::error::Result as DFResult;
use datafusion::logical_expr::dml::InsertOp;
use datafusion::logical_expr::{Expr, TableProviderFilterPushDown};
use datafusion::physical_plan::ExecutionPlan;
use paimon::table::Table;
use crate::physical_plan::PaimonDataSink;
use crate::error::to_datafusion_error;
#[cfg(test)]
use crate::filter_pushdown::build_pushed_predicate;
use crate::filter_pushdown::{analyze_filters, classify_filter_pushdown};
use crate::physical_plan::PaimonTableScan;
use crate::runtime::await_with_runtime;
/// Read-only table provider for a Paimon table.
///
/// Supports full table scan, column projection, and predicate pushdown for
/// planning. Partition predicates prune splits eagerly, while supported
/// non-partition data predicates may also be reused by the Parquet read path
/// for row-group pruning and partial decode-time filtering.
///
/// DataFusion still treats pushed filters as inexact because unsupported
/// predicates and non-Parquet reads remain residual filters.
#[derive(Debug, Clone)]
pub struct PaimonTableProvider {
table: Table,
schema: ArrowSchemaRef,
}
impl PaimonTableProvider {
/// Create a table provider from a Paimon table.
///
/// Loads the table schema and converts it to Arrow for DataFusion.
pub fn try_new(table: Table) -> DFResult<Self> {
let mut fields = table.schema().fields().to_vec();
let core_options = paimon::spec::CoreOptions::new(table.schema().options());
if core_options.data_evolution_enabled() {
fields.push(paimon::spec::DataField::new(
paimon::spec::ROW_ID_FIELD_ID,
paimon::spec::ROW_ID_FIELD_NAME.to_string(),
paimon::spec::DataType::BigInt(paimon::spec::BigIntType::with_nullable(true)),
));
}
let schema =
paimon::arrow::build_target_arrow_schema(&fields).map_err(to_datafusion_error)?;
Ok(Self { table, schema })
}
pub fn table(&self) -> &Table {
&self.table
}
}
/// Distribute `items` into `num_buckets` groups using round-robin assignment.
pub(crate) fn bucket_round_robin<T>(items: Vec<T>, num_buckets: usize) -> Vec<Vec<T>> {
let mut buckets: Vec<Vec<T>> = (0..num_buckets).map(|_| Vec::new()).collect();
for (index, item) in items.into_iter().enumerate() {
buckets[index % num_buckets].push(item);
}
buckets
}
/// Build parameters for [`PaimonTableScan`].
pub(crate) struct PaimonScanBuilder<'a> {
pub(crate) table: &'a Table,
pub(crate) schema: &'a ArrowSchemaRef,
pub(crate) plan: &'a paimon::table::Plan,
pub(crate) projection: Option<&'a Vec<usize>>,
pub(crate) pushed_predicate: Option<paimon::spec::Predicate>,
pub(crate) limit: Option<usize>,
pub(crate) target_partitions: usize,
pub(crate) filter_exact: bool,
}
impl PaimonScanBuilder<'_> {
/// Build a [`PaimonTableScan`] from the configured parameters.
pub(crate) fn build(self) -> DFResult<Arc<dyn ExecutionPlan>> {
let (projected_schema, projected_columns) = if let Some(indices) = self.projection {
let fields: Vec<Field> = indices
.iter()
.map(|&i| self.schema.field(i).clone())
.collect();
let column_names: Vec<String> = fields.iter().map(|f| f.name().clone()).collect();
(Arc::new(Schema::new(fields)), Some(column_names))
} else {
let column_names: Vec<String> = self
.schema
.fields()
.iter()
.map(|f| f.name().clone())
.collect();
(self.schema.clone(), Some(column_names))
};
let splits = self.plan.splits().to_vec();
let planned_partitions: Vec<Arc<[_]>> = if splits.is_empty() {
vec![Arc::from(Vec::new())]
} else {
let num_partitions = splits.len().min(self.target_partitions.max(1));
bucket_round_robin(splits, num_partitions)
.into_iter()
.map(Arc::from)
.collect()
};
Ok(Arc::new(PaimonTableScan::new(
projected_schema,
self.table.clone(),
projected_columns,
self.pushed_predicate,
planned_partitions,
self.limit,
self.filter_exact,
)))
}
}
#[async_trait]
impl TableProvider for PaimonTableProvider {
fn as_any(&self) -> &dyn Any {
self
}
fn schema(&self) -> ArrowSchemaRef {
self.schema.clone()
}
fn table_type(&self) -> TableType {
TableType::Base
}
async fn scan(
&self,
state: &dyn Session,
projection: Option<&Vec<usize>>,
filters: &[Expr],
limit: Option<usize>,
) -> DFResult<Arc<dyn ExecutionPlan>> {
// Plan splits eagerly so we know partition count upfront.
let filter_analysis = analyze_filters(filters, self.table.schema().fields());
let mut read_builder = self.table.new_read_builder();
if let Some(filter) = filter_analysis.pushed_predicate.clone() {
read_builder.with_filter(filter);
}
let pushed_limit = limit.filter(|_| !filter_analysis.has_untranslated_residual);
if let Some(limit) = pushed_limit {
read_builder.with_limit(limit);
}
let scan = read_builder.new_scan();
// DataFusion's Python FFI may poll `TableProvider::scan()` without an active
// Tokio runtime. `scan.plan()` can reach OpenDAL/Tokio filesystem calls while
// reading Paimon metadata, so we must provide a runtime here instead of
// assuming the caller already entered one.
let plan = await_with_runtime(scan.plan())
.await
.map_err(to_datafusion_error)?;
let target = state.config_options().execution.target_partitions;
let filter_exact = !filter_analysis.has_untranslated_residual
&& filter_analysis
.pushed_predicate
.as_ref()
.is_none_or(|p| read_builder.is_exact_filter_pushdown(p));
PaimonScanBuilder {
table: &self.table,
schema: &self.schema,
plan: &plan,
projection,
pushed_predicate: filter_analysis.pushed_predicate,
limit: pushed_limit,
target_partitions: target,
filter_exact,
}
.build()
}
async fn insert_into(
&self,
_state: &dyn Session,
input: Arc<dyn ExecutionPlan>,
insert_op: InsertOp,
) -> DFResult<Arc<dyn ExecutionPlan>> {
let overwrite = match insert_op {
InsertOp::Append => false,
InsertOp::Overwrite => true,
other => {
return Err(datafusion::error::DataFusionError::NotImplemented(format!(
"{other} is not supported for Paimon tables"
)));
}
};
let sink = PaimonDataSink::new(self.table.clone(), self.schema.clone(), overwrite);
Ok(Arc::new(DataSinkExec::new(input, Arc::new(sink), None)))
}
fn supports_filters_pushdown(
&self,
filters: &[&Expr],
) -> DFResult<Vec<TableProviderFilterPushDown>> {
let fields = self.table.schema().fields();
let read_builder = self.table.new_read_builder();
Ok(filters
.iter()
.map(|filter| {
classify_filter_pushdown(filter, fields, |predicate| {
read_builder.is_exact_filter_pushdown(predicate)
})
})
.collect())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeSet;
use std::sync::Arc;
use datafusion::datasource::TableProvider;
use datafusion::logical_expr::{col, lit, Expr};
use datafusion::prelude::{SessionConfig, SessionContext};
use paimon::catalog::Identifier;
use paimon::{Catalog, CatalogOptions, DataSplit, FileSystemCatalog, Options};
use crate::physical_plan::PaimonTableScan;
#[test]
fn test_bucket_round_robin_distributes_evenly() {
let result = bucket_round_robin(vec![0, 1, 2, 3, 4], 3);
assert_eq!(result, vec![vec![0, 3], vec![1, 4], vec![2]]);
}
#[test]
fn test_bucket_round_robin_fewer_items_than_buckets() {
let result = bucket_round_robin(vec![10, 20], 2);
assert_eq!(result, vec![vec![10], vec![20]]);
}
#[test]
fn test_bucket_round_robin_single_bucket() {
let result = bucket_round_robin(vec![1, 2, 3], 1);
assert_eq!(result, vec![vec![1, 2, 3]]);
}
fn get_test_warehouse() -> String {
std::env::var("PAIMON_TEST_WAREHOUSE")
.unwrap_or_else(|_| "/tmp/paimon-warehouse".to_string())
}
fn create_catalog() -> FileSystemCatalog {
let warehouse = get_test_warehouse();
let mut options = Options::new();
options.set(CatalogOptions::WAREHOUSE, warehouse);
FileSystemCatalog::new(options).expect("Failed to create catalog")
}
async fn create_provider(table_name: &str) -> PaimonTableProvider {
let catalog = create_catalog();
let identifier = Identifier::new("default", table_name);
let table = catalog
.get_table(&identifier)
.await
.expect("Failed to get table");
PaimonTableProvider::try_new(table).expect("Failed to create table provider")
}
async fn plan_partitions(
provider: &PaimonTableProvider,
filters: Vec<Expr>,
limit: Option<usize>,
) -> Vec<Arc<[DataSplit]>> {
let plan = plan_scan(provider, filters, limit).await;
let scan = plan
.as_any()
.downcast_ref::<PaimonTableScan>()
.expect("Expected PaimonTableScan");
scan.planned_partitions().to_vec()
}
async fn plan_scan(
provider: &PaimonTableProvider,
filters: Vec<Expr>,
limit: Option<usize>,
) -> Arc<dyn ExecutionPlan> {
let config = SessionConfig::new().with_target_partitions(8);
let ctx = SessionContext::new_with_config(config);
let state = ctx.state();
provider
.scan(&state, None, &filters, limit)
.await
.expect("scan() should succeed")
}
fn extract_dt_partition_set(planned_partitions: &[Arc<[DataSplit]>]) -> BTreeSet<String> {
planned_partitions
.iter()
.flat_map(|splits| splits.iter())
.map(|split| {
split
.partition()
.get_string(0)
.expect("Failed to decode dt")
.to_string()
})
.collect()
}
fn extract_dt_hr_partition_set(
planned_partitions: &[Arc<[DataSplit]>],
) -> BTreeSet<(String, i32)> {
planned_partitions
.iter()
.flat_map(|splits| splits.iter())
.map(|split| {
let partition = split.partition();
(
partition
.get_string(0)
.expect("Failed to decode dt")
.to_string(),
partition.get_int(1).expect("Failed to decode hr"),
)
})
.collect()
}
#[tokio::test]
async fn test_scan_partition_filter_plans_matching_partition_set() {
let provider = create_provider("partitioned_log_table").await;
let planned_partitions =
plan_partitions(&provider, vec![col("dt").eq(lit("2024-01-01"))], None).await;
assert_eq!(
extract_dt_partition_set(&planned_partitions),
BTreeSet::from(["2024-01-01".to_string()]),
);
}
#[tokio::test]
async fn test_scan_mixed_and_filter_keeps_partition_pruning() {
let provider = create_provider("partitioned_log_table").await;
let planned_partitions = plan_partitions(
&provider,
vec![col("dt").eq(lit("2024-01-01")).and(col("id").gt(lit(1)))],
None,
)
.await;
assert_eq!(
extract_dt_partition_set(&planned_partitions),
BTreeSet::from(["2024-01-01".to_string()]),
);
}
#[tokio::test]
async fn test_scan_multi_partition_filter_plans_exact_partition_set() {
let provider = create_provider("multi_partitioned_log_table").await;
let dt_only_partitions =
plan_partitions(&provider, vec![col("dt").eq(lit("2024-01-01"))], None).await;
let dt_hr_partitions = plan_partitions(
&provider,
vec![col("dt").eq(lit("2024-01-01")).and(col("hr").eq(lit(10)))],
None,
)
.await;
assert_eq!(
extract_dt_hr_partition_set(&dt_only_partitions),
BTreeSet::from([
("2024-01-01".to_string(), 10),
("2024-01-01".to_string(), 20),
]),
);
assert_eq!(
extract_dt_hr_partition_set(&dt_hr_partitions),
BTreeSet::from([("2024-01-01".to_string(), 10)]),
);
}
#[tokio::test]
async fn test_scan_partially_translated_filter_keeps_partition_pruning_but_skips_limit_hint() {
let provider = create_provider("multi_partitioned_log_table").await;
let filter = col("dt")
.eq(lit("2024-01-01"))
.and(Expr::Not(Box::new(col("hr").eq(lit(10)))));
let full_plan = plan_partitions(&provider, vec![filter.clone()], None).await;
let plan = plan_scan(&provider, vec![filter], Some(1)).await;
let scan = plan
.as_any()
.downcast_ref::<PaimonTableScan>()
.expect("Expected PaimonTableScan");
assert_eq!(scan.limit(), None);
assert_eq!(
extract_dt_hr_partition_set(scan.planned_partitions()),
BTreeSet::from([
("2024-01-01".to_string(), 10),
("2024-01-01".to_string(), 20),
]),
);
assert_eq!(
scan.planned_partitions()
.iter()
.map(|partition| partition.len())
.sum::<usize>(),
full_plan
.iter()
.map(|partition| partition.len())
.sum::<usize>()
);
}
#[tokio::test]
async fn test_scan_keeps_pushed_predicate_for_execute() {
let provider = create_provider("partitioned_log_table").await;
let filter = col("id").gt(lit(1));
let config = SessionConfig::new().with_target_partitions(8);
let ctx = SessionContext::new_with_config(config);
let state = ctx.state();
let plan = provider
.scan(&state, None, std::slice::from_ref(&filter), None)
.await
.expect("scan() should succeed");
let scan = plan
.as_any()
.downcast_ref::<PaimonTableScan>()
.expect("Expected PaimonTableScan");
let expected = build_pushed_predicate(&[filter], provider.table().schema().fields())
.expect("data filter should translate");
assert_eq!(scan.pushed_predicate(), Some(&expected));
}
#[tokio::test]
async fn test_scan_applies_limit_hint_only_when_safe() {
let provider = create_provider("partitioned_log_table").await;
let full_plan = plan_partitions(&provider, vec![], None).await;
let plan = plan_scan(&provider, vec![], Some(1)).await;
let scan = plan
.as_any()
.downcast_ref::<PaimonTableScan>()
.expect("Expected PaimonTableScan");
assert_eq!(scan.limit(), Some(1));
assert!(
scan.planned_partitions()
.iter()
.map(|partition| partition.len())
.sum::<usize>()
< full_plan
.iter()
.map(|partition| partition.len())
.sum::<usize>()
);
}
#[tokio::test]
async fn test_scan_keeps_limit_but_skips_limit_pruning_for_data_filters() {
let provider = create_provider("partitioned_log_table").await;
let filter = col("id").gt(lit(1));
let full_plan = plan_partitions(&provider, vec![filter.clone()], None).await;
let plan = plan_scan(&provider, vec![filter], Some(1)).await;
let scan = plan
.as_any()
.downcast_ref::<PaimonTableScan>()
.expect("Expected PaimonTableScan");
assert_eq!(scan.limit(), Some(1));
assert_eq!(
scan.planned_partitions()
.iter()
.map(|partition| partition.len())
.sum::<usize>(),
full_plan
.iter()
.map(|partition| partition.len())
.sum::<usize>()
);
}
#[tokio::test]
async fn test_insert_into_and_read_back() {
use paimon::io::FileIOBuilder;
use paimon::spec::{DataType, IntType, Schema as PaimonSchema, TableSchema};
let file_io = FileIOBuilder::new("memory").build().unwrap();
let table_path = "memory:/test_df_insert_into";
file_io
.mkdirs(&format!("{table_path}/snapshot/"))
.await
.unwrap();
file_io
.mkdirs(&format!("{table_path}/manifest/"))
.await
.unwrap();
let schema = PaimonSchema::builder()
.column("id", DataType::Int(IntType::new()))
.column("value", DataType::Int(IntType::new()))
.build()
.unwrap();
let table_schema = TableSchema::new(0, &schema);
let table = paimon::table::Table::new(
file_io,
Identifier::new("default", "test_insert"),
table_path.to_string(),
table_schema,
None,
);
let provider = PaimonTableProvider::try_new(table).unwrap();
let ctx = SessionContext::new();
ctx.register_table("t", Arc::new(provider)).unwrap();
// INSERT INTO
let result = ctx
.sql("INSERT INTO t VALUES (1, 10), (2, 20), (3, 30)")
.await
.unwrap()
.collect()
.await
.unwrap();
// Verify count output
let count_array = result[0]
.column(0)
.as_any()
.downcast_ref::<datafusion::arrow::array::UInt64Array>()
.unwrap();
assert_eq!(count_array.value(0), 3);
// Read back
let batches = ctx
.sql("SELECT id, value FROM t ORDER BY id")
.await
.unwrap()
.collect()
.await
.unwrap();
let mut rows = Vec::new();
for batch in &batches {
let ids = batch
.column(0)
.as_any()
.downcast_ref::<datafusion::arrow::array::Int32Array>()
.unwrap();
let vals = batch
.column(1)
.as_any()
.downcast_ref::<datafusion::arrow::array::Int32Array>()
.unwrap();
for i in 0..batch.num_rows() {
rows.push((ids.value(i), vals.value(i)));
}
}
assert_eq!(rows, vec![(1, 10), (2, 20), (3, 30)]);
}
#[tokio::test]
async fn test_insert_overwrite() {
use paimon::io::FileIOBuilder;
use paimon::spec::{DataType, IntType, Schema as PaimonSchema, TableSchema, VarCharType};
let file_io = FileIOBuilder::new("memory").build().unwrap();
let table_path = "memory:/test_df_insert_overwrite";
file_io
.mkdirs(&format!("{table_path}/snapshot/"))
.await
.unwrap();
file_io
.mkdirs(&format!("{table_path}/manifest/"))
.await
.unwrap();
let schema = PaimonSchema::builder()
.column("pt", DataType::VarChar(VarCharType::string_type()))
.column("id", DataType::Int(IntType::new()))
.partition_keys(["pt"])
.build()
.unwrap();
let table_schema = TableSchema::new(0, &schema);
let table = paimon::table::Table::new(
file_io,
Identifier::new("default", "test_overwrite"),
table_path.to_string(),
table_schema,
None,
);
let provider = PaimonTableProvider::try_new(table).unwrap();
let ctx = SessionContext::new();
ctx.register_table("t", Arc::new(provider)).unwrap();
// Initial INSERT: partition "a" and "b"
ctx.sql("INSERT INTO t VALUES ('a', 1), ('a', 2), ('b', 3), ('b', 4)")
.await
.unwrap()
.collect()
.await
.unwrap();
// INSERT OVERWRITE with only partition "a" data
// Should overwrite partition "a" but leave partition "b" intact
ctx.sql("INSERT OVERWRITE t VALUES ('a', 10), ('a', 20)")
.await
.unwrap()
.collect()
.await
.unwrap();
// Read back
let batches = ctx
.sql("SELECT pt, id FROM t ORDER BY pt, id")
.await
.unwrap()
.collect()
.await
.unwrap();
let mut rows = Vec::new();
for batch in &batches {
let pts = batch
.column(0)
.as_any()
.downcast_ref::<datafusion::arrow::array::StringArray>()
.unwrap();
let ids = batch
.column(1)
.as_any()
.downcast_ref::<datafusion::arrow::array::Int32Array>()
.unwrap();
for i in 0..batch.num_rows() {
rows.push((pts.value(i).to_string(), ids.value(i)));
}
}
// Partition "a" overwritten with new data, partition "b" untouched
assert_eq!(
rows,
vec![
("a".to_string(), 10),
("a".to_string(), 20),
("b".to_string(), 3),
("b".to_string(), 4),
]
);
}
#[tokio::test]
async fn test_insert_overwrite_unpartitioned() {
use paimon::io::FileIOBuilder;
use paimon::spec::{DataType, IntType, Schema as PaimonSchema, TableSchema};
let file_io = FileIOBuilder::new("memory").build().unwrap();
let table_path = "memory:/test_df_insert_overwrite_unpart";
file_io
.mkdirs(&format!("{table_path}/snapshot/"))
.await
.unwrap();
file_io
.mkdirs(&format!("{table_path}/manifest/"))
.await
.unwrap();
let schema = PaimonSchema::builder()
.column("id", DataType::Int(IntType::new()))
.column("value", DataType::Int(IntType::new()))
.build()
.unwrap();
let table_schema = TableSchema::new(0, &schema);
let table = paimon::table::Table::new(
file_io,
Identifier::new("default", "test_overwrite_unpart"),
table_path.to_string(),
table_schema,
None,
);
let provider = PaimonTableProvider::try_new(table).unwrap();
let ctx = SessionContext::new();
ctx.register_table("t", Arc::new(provider)).unwrap();
// Initial INSERT
ctx.sql("INSERT INTO t VALUES (1, 10), (2, 20), (3, 30)")
.await
.unwrap()
.collect()
.await
.unwrap();
// INSERT OVERWRITE on unpartitioned table — full table overwrite
ctx.sql("INSERT OVERWRITE t VALUES (4, 40), (5, 50)")
.await
.unwrap()
.collect()
.await
.unwrap();
let batches = ctx
.sql("SELECT id, value FROM t ORDER BY id")
.await
.unwrap()
.collect()
.await
.unwrap();
let mut rows = Vec::new();
for batch in &batches {
let ids = batch
.column(0)
.as_any()
.downcast_ref::<datafusion::arrow::array::Int32Array>()
.unwrap();
let vals = batch
.column(1)
.as_any()
.downcast_ref::<datafusion::arrow::array::Int32Array>()
.unwrap();
for i in 0..batch.num_rows() {
rows.push((ids.value(i), vals.value(i)));
}
}
// Old data fully replaced
assert_eq!(rows, vec![(4, 40), (5, 50)]);
}
}