This is an automated email from the ASF dual-hosted git repository.

JingsongLi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-rust.git


The following commit(s) were added to refs/heads/main by this push:
     new 9545797  fix(datafusion): guard cow merge partition pruning (#475)
9545797 is described below

commit 95457972e389c302d544c51afa774c130d34905b
Author: 承昭 <[email protected]>
AuthorDate: Thu Jul 9 10:37:41 2026 +0800

    fix(datafusion): guard cow merge partition pruning (#475)
---
 crates/integrations/datafusion/src/merge_into.rs   | 131 ++++++++++++++++++++-
 .../datafusion/tests/append_merge_into.rs          |  28 +++++
 2 files changed, 155 insertions(+), 4 deletions(-)

diff --git a/crates/integrations/datafusion/src/merge_into.rs 
b/crates/integrations/datafusion/src/merge_into.rs
index 203538d..124dd65 100644
--- a/crates/integrations/datafusion/src/merge_into.rs
+++ b/crates/integrations/datafusion/src/merge_into.rs
@@ -32,7 +32,8 @@ use datafusion::arrow::datatypes::{DataType as ArrowDataType, 
Field, Schema};
 use datafusion::error::{DataFusionError, Result as DFResult};
 use datafusion::prelude::{DataFrame, SessionContext};
 use datafusion::sql::sqlparser::ast::{
-    AssignmentTarget, Merge, MergeAction, MergeClauseKind, MergeInsertKind, 
TableFactor,
+    AssignmentTarget, BinaryOperator, Expr as SqlExpr, Ident, Merge, 
MergeAction, MergeClauseKind,
+    MergeInsertKind, TableFactor,
 };
 use futures::TryStreamExt;
 
@@ -325,12 +326,14 @@ async fn execute_cow_merge_once(
 
     let (source_ref, source_alias) = extract_source_ref(&merge.source)?;
     let (target_ref, target_alias) = extract_table_ref(&merge.table)?;
-    let on_condition = merge.on.to_string();
+    let on_expr = &merge.on;
+    let on_condition = on_expr.to_string();
     let s_alias = source_alias.as_deref().unwrap_or(&source_ref);
     let t_alias = target_alias.as_deref().unwrap_or("__cow_t");
 
     // Build partition filter from source data to avoid scanning all partitions
-    let partition_set = build_source_partition_set(ctx, table, &source_ref, 
s_alias).await?;
+    let partition_set =
+        build_source_partition_set(ctx, table, &source_ref, s_alias, t_alias, 
on_expr).await?;
 
     let mut writer = CopyOnWriteMergeWriter::new(table, 
update_columns.clone(), partition_set)
         .await
@@ -1365,17 +1368,24 @@ pub(crate) async fn build_partition_set_from_where(
 /// Query source table for distinct partition values and build a partition set.
 ///
 /// Returns `None` for non-partitioned tables or when the source lacks matching
-/// partition key columns (falls back to full-partition scan).
+/// partition key columns (falls back to full-partition scan). The source 
values
+/// are only safe to apply to the target when the MERGE condition equates every
+/// target partition column with the same source column.
 async fn build_source_partition_set(
     ctx: &SQLContext,
     table: &Table,
     source_ref: &str,
     s_alias: &str,
+    t_alias: &str,
+    on_expr: &SqlExpr,
 ) -> DFResult<Option<HashSet<Vec<u8>>>> {
     let partition_keys = table.schema().partition_keys();
     if partition_keys.is_empty() {
         return Ok(None);
     }
+    if !can_prune_target_by_source_partitions(partition_keys, t_alias, 
s_alias, on_expr) {
+        return Ok(None);
+    }
 
     let cols = partition_keys
         .iter()
@@ -1392,6 +1402,71 @@ async fn build_source_partition_set(
     }
 }
 
+fn can_prune_target_by_source_partitions(
+    partition_keys: &[String],
+    t_alias: &str,
+    s_alias: &str,
+    on_expr: &SqlExpr,
+) -> bool {
+    partition_keys
+        .iter()
+        .all(|key| on_expr_contains_alias_column_eq(on_expr, t_alias, key, 
s_alias, key))
+}
+
+fn on_expr_contains_alias_column_eq(
+    expr: &SqlExpr,
+    left_alias: &str,
+    left_column: &str,
+    right_alias: &str,
+    right_column: &str,
+) -> bool {
+    match expr {
+        SqlExpr::BinaryOp {
+            left,
+            op: BinaryOperator::And,
+            right,
+        } => {
+            on_expr_contains_alias_column_eq(
+                left,
+                left_alias,
+                left_column,
+                right_alias,
+                right_column,
+            ) || on_expr_contains_alias_column_eq(
+                right,
+                left_alias,
+                left_column,
+                right_alias,
+                right_column,
+            )
+        }
+        SqlExpr::BinaryOp {
+            left,
+            op: BinaryOperator::Eq,
+            right,
+        } => {
+            is_alias_column(left, left_alias, left_column)
+                && is_alias_column(right, right_alias, right_column)
+                || is_alias_column(left, right_alias, right_column)
+                    && is_alias_column(right, left_alias, left_column)
+        }
+        _ => false,
+    }
+}
+
+fn is_alias_column(expr: &SqlExpr, alias: &str, column: &str) -> bool {
+    match expr {
+        SqlExpr::CompoundIdentifier(parts) => is_qualified_column(parts, 
alias, column),
+        _ => false,
+    }
+}
+
+fn is_qualified_column(parts: &[Ident], alias: &str, column: &str) -> bool {
+    parts.len() == 2
+        && parts[0].value.eq_ignore_ascii_case(alias)
+        && parts[1].value.eq_ignore_ascii_case(column)
+}
+
 /// Rewrite SQL expressions by replacing original table references with 
aliases.
 ///
 /// For example, `paimon.test_db.target.a = source.a` becomes `t.a = s.a`
@@ -1536,6 +1611,54 @@ mod tests {
         }
     }
 
+    #[test]
+    fn test_source_partition_pruning_requires_partition_equality() {
+        let merge = parse_merge(
+            "MERGE INTO target t USING source s ON t.a = s.a \
+             WHEN MATCHED THEN UPDATE SET b = s.b",
+        );
+        let partition_keys = vec!["pt".to_string()];
+
+        assert!(!can_prune_target_by_source_partitions(
+            &partition_keys,
+            "t",
+            "s",
+            &merge.on,
+        ));
+    }
+
+    #[test]
+    fn test_source_partition_pruning_accepts_partition_equality() {
+        let merge = parse_merge(
+            "MERGE INTO target t USING source s ON t.a = s.a AND t.pt = s.pt \
+             WHEN MATCHED THEN UPDATE SET b = s.b",
+        );
+        let partition_keys = vec!["pt".to_string()];
+
+        assert!(can_prune_target_by_source_partitions(
+            &partition_keys,
+            "t",
+            "s",
+            &merge.on,
+        ));
+    }
+
+    #[test]
+    fn test_source_partition_pruning_accepts_reversed_partition_equality() {
+        let merge = parse_merge(
+            "MERGE INTO target t USING source s ON s.pt = t.pt AND t.a = s.a \
+             WHEN MATCHED THEN UPDATE SET b = s.b",
+        );
+        let partition_keys = vec!["pt".to_string()];
+
+        assert!(can_prune_target_by_source_partitions(
+            &partition_keys,
+            "t",
+            "s",
+            &merge.on,
+        ));
+    }
+
     #[tokio::test]
     async fn test_merge_into_updates_matched_rows() {
         let (_tmp, sql_context, table) = 
setup_data_evolution_table("t_merge").await;
diff --git a/crates/integrations/datafusion/tests/append_merge_into.rs 
b/crates/integrations/datafusion/tests/append_merge_into.rs
index c14936c..c9d66bb 100644
--- a/crates/integrations/datafusion/tests/append_merge_into.rs
+++ b/crates/integrations/datafusion/tests/append_merge_into.rs
@@ -1132,6 +1132,34 @@ async fn test_partitioned_update_and_insert() {
     );
 }
 
+#[tokio::test]
+async fn 
test_partitioned_merge_without_partition_equality_does_not_prune_target() {
+    let (_tmp, sql_context) =
+        setup("CREATE TABLE paimon.test_db.target (a INT, b INT, pt INT) 
PARTITIONED BY (pt)")
+            .await;
+    exec(
+        &sql_context,
+        "INSERT INTO paimon.test_db.target VALUES (1, 10, 2)",
+    )
+    .await;
+    exec(
+        &sql_context,
+        "CREATE TEMPORARY TABLE paimon.test_db.source (a INT, b INT, pt INT) 
AS SELECT * FROM (VALUES (1, 100, 1)) AS t(a, b, pt)",
+    )
+    .await;
+
+    exec(
+        &sql_context,
+        "MERGE INTO paimon.test_db.target t \
+         USING paimon.test_db.source s ON t.a = s.a \
+         WHEN MATCHED THEN UPDATE SET b = s.b \
+         WHEN NOT MATCHED THEN INSERT (a, b, pt) VALUES (s.a, s.b, s.pt)",
+    )
+    .await;
+
+    assert_eq!(query_a_b_pt(&sql_context).await, vec![(1, 100, 2),]);
+}
+
 #[tokio::test]
 async fn test_partitioned_successive_merges() {
     let (_tmp, sql_context) = setup_partitioned().await;

Reply via email to