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 18fc5797 fix(datafusion): map column-less MERGE INSERT VALUES by 
position (#984)
18fc5797 is described below

commit 18fc579705bbed61a2f82b589c5e51f5c9397b7c
Author: jackylee <[email protected]>
AuthorDate: Sat Oct 3 18:07:21 2026 +0800

    fix(datafusion): map column-less MERGE INSERT VALUES by position (#984)
---
 crates/integrations/datafusion/src/merge_into.rs | 187 ++++++++++++++++++++---
 1 file changed, 165 insertions(+), 22 deletions(-)

diff --git a/crates/integrations/datafusion/src/merge_into.rs 
b/crates/integrations/datafusion/src/merge_into.rs
index 0bb9a3b2..15e8c82e 100644
--- a/crates/integrations/datafusion/src/merge_into.rs
+++ b/crates/integrations/datafusion/src/merge_into.rs
@@ -907,7 +907,7 @@ async fn build_insert_batches_inner(
             format!(" WHERE {}", conditions.join(" AND "))
         };
 
-        let select_clause = insert_select_clause(ins, table_fields);
+        let select_clause = insert_select_clause(ins, table_fields)?;
         let sql = format!("SELECT {select_clause} FROM {tmp_name} AS 
{s_alias}{where_clause}");
 
         let batches = ctx.ctx().sql(&sql).await?.collect().await?;
@@ -995,32 +995,56 @@ fn strip_non_source_columns(
 ///
 /// When the INSERT specifies explicit columns (`INSERT (col2, col1) VALUES 
(expr2, expr1)`),
 /// the output must be reordered to match the table schema so that 
`write_arrow_batch`
-/// (which reads columns by positional index) maps them correctly.
-fn insert_select_clause(ins: &MergeInsertClause, table_fields: &[DataField]) 
-> String {
+/// (which reads columns by positional index) maps them correctly. When the 
INSERT omits
+/// the column list (`INSERT VALUES (expr1, expr2, ...)`), the values map to 
the table's
+/// columns by position, following standard SQL, where the MERGE INSERT column 
list is
+/// optional (unlike Spark, whose grammar requires an explicit list or `INSERT 
*`).
+fn insert_select_clause(ins: &MergeInsertClause, table_fields: &[DataField]) 
-> DFResult<String> {
     if ins.columns.is_empty() && ins.value_exprs.is_empty() {
-        "*".to_string()
-    } else {
-        // Build column_name -> expression mapping from the INSERT clause
-        let col_expr_map: HashMap<&str, &str> = ins
-            .columns
-            .iter()
-            .zip(ins.value_exprs.iter())
-            .map(|(col, expr)| (col.as_str(), expr.as_str()))
-            .collect();
+        return Ok("*".to_string());
+    }
 
-        // Emit SELECT in table schema order
-        table_fields
+    if ins.columns.is_empty() {
+        // No explicit column list: the values fill the table's columns by
+        // position. Without this branch the empty column->expr map below would
+        // emit `NULL` for every column and silently drop the source values,
+        // inserting all-NULL rows (or failing on a non-null column).
+        if ins.value_exprs.len() != table_fields.len() {
+            return Err(DataFusionError::Plan(format!(
+                "MERGE INSERT has {} value(s) but the table has {} column(s); \
+                 list the target columns explicitly or provide one value per 
column",
+                ins.value_exprs.len(),
+                table_fields.len()
+            )));
+        }
+        return Ok(table_fields
             .iter()
-            .map(|field| {
-                match col_expr_map.get(field.name()) {
-                    Some(expr) => format!("{expr} AS {}", 
quote_identifier(field.name())),
-                    // Column not in INSERT list — fill with NULL
-                    None => format!("NULL AS {}", 
quote_identifier(field.name())),
-                }
-            })
+            .zip(ins.value_exprs.iter())
+            .map(|(field, expr)| format!("{expr} AS {}", 
quote_identifier(field.name())))
             .collect::<Vec<_>>()
-            .join(", ")
+            .join(", "));
     }
+
+    // Build column_name -> expression mapping from the INSERT clause
+    let col_expr_map: HashMap<&str, &str> = ins
+        .columns
+        .iter()
+        .zip(ins.value_exprs.iter())
+        .map(|(col, expr)| (col.as_str(), expr.as_str()))
+        .collect();
+
+    // Emit SELECT in table schema order
+    Ok(table_fields
+        .iter()
+        .map(|field| {
+            match col_expr_map.get(field.name()) {
+                Some(expr) => format!("{expr} AS {}", 
quote_identifier(field.name())),
+                // Column not in INSERT list — fill with NULL
+                None => format!("NULL AS {}", quote_identifier(field.name())),
+            }
+        })
+        .collect::<Vec<_>>()
+        .join(", "))
 }
 
 /// Parsed WHEN NOT MATCHED THEN INSERT clause.
@@ -1078,6 +1102,24 @@ fn validate_merge_insert_columns(
 ) -> DFResult<()> {
     for insert in inserts {
         validate_target_columns(&insert.columns, table_fields, "MERGE 
INSERT")?;
+        // A column-less positional INSERT maps its VALUES to the table 
columns by
+        // position, so the value count must match the column count. Validate 
it
+        // here, upfront: this runs in both CoW and data-evolution modes 
before any
+        // batch is built, whereas `insert_select_clause` only runs when 
unmatched
+        // rows exist — an all-matched MERGE would otherwise skip the arity 
check
+        // and commit. `INSERT *`/`INSERT ROW` (no columns and no values) is 
left
+        // untouched.
+        if insert.columns.is_empty()
+            && !insert.value_exprs.is_empty()
+            && insert.value_exprs.len() != table_fields.len()
+        {
+            return Err(DataFusionError::Plan(format!(
+                "MERGE INSERT has {} value(s) but the table has {} column(s); \
+                 list the target columns explicitly or provide one value per 
column",
+                insert.value_exprs.len(),
+                table_fields.len()
+            )));
+        }
     }
 
     Ok(())
@@ -2191,6 +2233,107 @@ mod tests {
         );
     }
 
+    #[tokio::test]
+    async fn test_cow_merge_insert_not_matched_without_columns() {
+        let (_tmp, sql_context, table) = 
setup_append_only_table("t_cow_ins_nocols").await;
+
+        sql_context
+            .sql("CREATE TABLE paimon.test_db.source (id INT, name VARCHAR, 
value INT)")
+            .await
+            .unwrap();
+        sql_context
+            .sql("INSERT INTO paimon.test_db.source VALUES (4, 'dave', 40), 
(5, 'eve', 50)")
+            .await
+            .unwrap()
+            .collect()
+            .await
+            .unwrap();
+
+        // No column list: the VALUES map to the table's columns by position.
+        // Before the fix this dropped the source values and inserted all-NULL 
rows.
+        let merge = parse_merge(
+            "MERGE INTO paimon.test_db.t_cow_ins_nocols t USING 
paimon.test_db.source s \
+             ON t.id = s.id \
+             WHEN NOT MATCHED THEN INSERT VALUES (s.id, s.name, s.value)",
+        );
+        execute_merge_into(&sql_context, &merge, table, true)
+            .await
+            .unwrap();
+
+        let batches = sql_context
+            .sql("SELECT id, name, value FROM paimon.test_db.t_cow_ins_nocols 
ORDER BY id")
+            .await
+            .unwrap()
+            .collect()
+            .await
+            .unwrap();
+
+        let rows = collect_rows(&batches);
+        assert_eq!(
+            rows,
+            vec![
+                (1, "alice".to_string(), 10),
+                (2, "bob".to_string(), 20),
+                (3, "charlie".to_string(), 30),
+                (4, "dave".to_string(), 40),
+                (5, "eve".to_string(), 50),
+            ]
+        );
+    }
+
+    #[tokio::test]
+    async fn test_cow_merge_insert_arity_validated_even_when_all_matched() {
+        let (_tmp, sql_context, table) = 
setup_append_only_table("t_cow_ins_arity").await;
+
+        sql_context
+            .sql("CREATE TABLE paimon.test_db.source (id INT, name VARCHAR, 
value INT)")
+            .await
+            .unwrap();
+        // id = 1 matches the target, so the NOT MATCHED INSERT branch is never
+        // built — the arity check must still run upfront.
+        sql_context
+            .sql("INSERT INTO paimon.test_db.source VALUES (1, 'ALICE', 99)")
+            .await
+            .unwrap()
+            .collect()
+            .await
+            .unwrap();
+
+        // The column-less INSERT lists 2 values for a 3-column table. Even 
with no
+        // row to insert, the invalid arity must be rejected before execution 
so the
+        // matched UPDATE is not applied.
+        let merge = parse_merge(
+            "MERGE INTO paimon.test_db.t_cow_ins_arity t USING 
paimon.test_db.source s \
+             ON t.id = s.id \
+             WHEN MATCHED THEN UPDATE SET value = s.value \
+             WHEN NOT MATCHED THEN INSERT VALUES (s.id, s.name)",
+        );
+        let err = execute_merge_into(&sql_context, &merge, table, true)
+            .await
+            .unwrap_err();
+        assert!(
+            err.to_string().contains("value(s) but the table has"),
+            "got {err}"
+        );
+
+        // The target is unchanged: the matched UPDATE did not apply.
+        let batches = sql_context
+            .sql("SELECT id, name, value FROM paimon.test_db.t_cow_ins_arity 
ORDER BY id")
+            .await
+            .unwrap()
+            .collect()
+            .await
+            .unwrap();
+        assert_eq!(
+            collect_rows(&batches),
+            vec![
+                (1, "alice".to_string(), 10),
+                (2, "bob".to_string(), 20),
+                (3, "charlie".to_string(), 30),
+            ]
+        );
+    }
+
     #[tokio::test]
     async fn test_cow_merge_update_and_insert() {
         let (_tmp, sql_context, table) = 
setup_append_only_table("t_cow_upsert").await;

Reply via email to