blob: 143d43120d0cce61adfd94375e238000a46c4cb5 [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.
//! UPDATE execution for Paimon tables.
//!
//! Supports two execution paths:
//! - **Data evolution tables**: partial-column writes via [`paimon::table::DataEvolutionWriter`].
//! - **Append-only tables** (no PK, no deletion vectors): copy-on-write file rewriting
//! via [`paimon::table::CopyOnWriteMergeWriter`].
use std::sync::Arc;
use datafusion::arrow::array::{Array, RecordBatch};
use datafusion::arrow::datatypes::{Field, Schema};
use datafusion::error::{DataFusionError, Result as DFResult};
use datafusion::prelude::{DataFrame, SessionContext};
use datafusion::sql::sqlparser::ast::{AssignmentTarget, TableFactor, Update};
use paimon::spec::CoreOptions;
use paimon::table::{CopyOnWriteMergeWriter, DataEvolutionWriter, Table};
use crate::error::to_datafusion_error;
use crate::merge_into::{
build_partition_set_from_where, extract_tracking_columns, is_delete_conflict,
is_row_id_conflict, ok_result, project_update_columns, quote_identifier,
register_cow_target_table, retry_on_conflict, TempTableTracker,
};
use crate::sql_context::SQLContext;
/// Execute an UPDATE statement on a Paimon table.
pub(crate) async fn execute_update(
ctx: &SQLContext,
update: &Update,
table: Table,
) -> DFResult<DataFrame> {
if let TableFactor::Table { alias: Some(a), .. } = &update.table.relation {
return Err(DataFusionError::Plan(format!(
"Table alias '{}' in UPDATE is not yet supported",
a.name.value
)));
}
let schema = table.schema();
let core_options = CoreOptions::new(schema.options());
if core_options.data_evolution_enabled() {
execute_data_evolution_update(ctx, update, table).await
} else if schema.trimmed_primary_keys().is_empty() {
execute_cow_update(ctx, update, &table).await
} else {
Err(DataFusionError::Plan(
"UPDATE on primary-key tables without data-evolution is not supported".to_string(),
))
}
}
// ---------------------------------------------------------------------------
// Data evolution path
// ---------------------------------------------------------------------------
/// Execute UPDATE on a data evolution table with retry on row ID conflict.
async fn execute_data_evolution_update(
ctx: &SQLContext,
update: &Update,
table: Table,
) -> DFResult<DataFrame> {
retry_on_conflict("UPDATE", is_row_id_conflict, || {
execute_update_once(ctx, update, &table)
})
.await
}
/// Single attempt of UPDATE execution.
async fn execute_update_once(
ctx: &SQLContext,
update: &Update,
table: &Table,
) -> DFResult<DataFrame> {
// 1. Extract SET assignments
let mut columns = Vec::new();
let mut exprs = Vec::new();
for assignment in &update.assignments {
let col_name = match &assignment.target {
AssignmentTarget::ColumnName(name) => name
.0
.last()
.and_then(|p| p.as_ident())
.map(|id| id.value.clone())
.ok_or_else(|| {
DataFusionError::Plan(format!("Invalid column name in SET: {name}"))
})?,
AssignmentTarget::Tuple(_) => {
return Err(DataFusionError::Plan(
"Tuple assignment in UPDATE SET is not supported".to_string(),
));
}
};
columns.push(col_name);
exprs.push(assignment.value.to_string());
}
// 2. Create DataEvolutionWriter (validates preconditions)
let mut writer =
DataEvolutionWriter::new(table, columns.clone()).map_err(to_datafusion_error)?;
// 3. Query the target table directly with WHERE filter.
let table_ref = update.table.to_string();
let select_parts: Vec<String> =
std::iter::once("\"_ROW_ID\"".to_string())
.chain(columns.iter().zip(exprs.iter()).map(|(col, expr)| {
format!("{expr} AS {}", quote_identifier(&format!("__upd_{col}")))
}))
.collect();
let select_clause = select_parts.join(", ");
let where_clause = match &update.selection {
Some(expr) => format!(" WHERE {expr}"),
None => String::new(),
};
let query_sql = format!("SELECT {select_clause} FROM {table_ref}{where_clause}");
let batches = ctx.ctx().sql(&query_sql).await?.collect().await?;
// 4. Project update columns (rename __upd_X → X)
let total_count: u64 = batches.iter().map(|b| b.num_rows() as u64).sum();
if total_count == 0 {
return ok_result(ctx.ctx(), 0);
}
let update_batches = project_update_columns(&batches, &columns)?;
for batch in update_batches {
writer
.add_matched_batch(batch)
.map_err(to_datafusion_error)?;
}
// 5. Commit
let messages = writer.prepare_commit().await.map_err(to_datafusion_error)?;
if !messages.is_empty() {
let commit = table.new_write_builder().new_commit();
commit.commit(messages).await.map_err(to_datafusion_error)?;
}
ok_result(ctx.ctx(), total_count)
}
// ---------------------------------------------------------------------------
// Copy-on-Write path (append-only tables, no PK)
// ---------------------------------------------------------------------------
/// Execute UPDATE on an append-only table with retry on delete conflict.
async fn execute_cow_update(
ctx: &SQLContext,
update: &Update,
table: &Table,
) -> DFResult<DataFrame> {
retry_on_conflict("CoW UPDATE", is_delete_conflict, || {
execute_cow_update_once(ctx, update, table)
})
.await
}
/// Single attempt of CoW UPDATE execution.
async fn execute_cow_update_once(
ctx: &SQLContext,
update: &Update,
table: &Table,
) -> DFResult<DataFrame> {
let (columns, exprs) = extract_set_assignments(update)?;
let table_ref = update.table.to_string();
let where_str = update.selection.as_ref().map(|e| e.to_string());
let partition_set =
build_partition_set_from_where(ctx, table, &table_ref, where_str.as_deref()).await?;
let mut writer = CopyOnWriteMergeWriter::new(table, columns.clone(), partition_set)
.await
.map_err(to_datafusion_error)?;
let mut temp_tracker = TempTableTracker::new(ctx);
let (has_data, cow_table_name) =
register_cow_target_table(ctx, table, &writer, &mut temp_tracker).await?;
if !has_data {
return ok_result(ctx.ctx(), 0);
}
let cow_target_qualified = cow_table_name;
let result = execute_cow_update_inner(
ctx.ctx(),
&columns,
&exprs,
&cow_target_qualified,
update,
&mut writer,
)
.await;
let total_count = result?;
let messages = writer.prepare_commit().await.map_err(to_datafusion_error)?;
if !messages.is_empty() {
let commit = table.new_write_builder().new_commit();
commit.commit(messages).await.map_err(to_datafusion_error)?;
}
ok_result(ctx.ctx(), total_count)
}
async fn execute_cow_update_inner(
ctx: &SessionContext,
columns: &[String],
exprs: &[String],
cow_table_name: &str,
update: &Update,
writer: &mut CopyOnWriteMergeWriter,
) -> DFResult<u64> {
let select_parts: Vec<String> =
std::iter::once("\"__paimon_file_idx\"".to_string())
.chain(std::iter::once("\"__paimon_row_offset\"".to_string()))
.chain(columns.iter().zip(exprs.iter()).map(|(col, expr)| {
format!("{expr} AS {}", quote_identifier(&format!("__upd_{col}")))
}))
.collect();
let select_clause = select_parts.join(", ");
let where_clause = match &update.selection {
Some(expr) => format!(" WHERE {expr}"),
None => String::new(),
};
// Safety: where_clause comes from sqlparser AST to_string(), not raw user input.
let query_sql = format!("SELECT {select_clause} FROM {cow_table_name}{where_clause}");
let join_result = ctx.sql(&query_sql).await?.collect().await?;
let mut update_value_batches: Vec<RecordBatch> = Vec::new();
let mut batch_counter: usize = 0;
let mut total_count: u64 = 0;
for batch in &join_result {
if batch.num_rows() == 0 {
continue;
}
let (file_idx_col, row_offset_col) = extract_tracking_columns(batch)?;
let mut upd_fields = Vec::new();
let mut upd_columns: Vec<Arc<dyn Array>> = Vec::new();
for col in columns {
let prefixed = format!("__upd_{col}");
let idx = batch.schema().index_of(&prefixed).map_err(|e| {
DataFusionError::Internal(format!("Column {prefixed} not found: {e}"))
})?;
upd_fields.push(Field::new(
col,
batch.schema().field(idx).data_type().clone(),
true,
));
upd_columns.push(batch.column(idx).clone());
}
let upd_schema = Arc::new(Schema::new(upd_fields));
let upd_batch = RecordBatch::try_new(upd_schema, upd_columns)?;
let current_batch_idx = batch_counter;
update_value_batches.push(upd_batch);
batch_counter += 1;
for row in 0..batch.num_rows() {
let file_idx = file_idx_col.value(row) as usize;
let row_offset = row_offset_col.value(row) as usize;
writer.add_matched_update(file_idx, row_offset, current_batch_idx, row);
total_count += 1;
}
}
if !update_value_batches.is_empty() {
writer.set_update_batches(update_value_batches);
}
Ok(total_count)
}
/// Extract column names and expressions from UPDATE SET assignments.
fn extract_set_assignments(update: &Update) -> DFResult<(Vec<String>, Vec<String>)> {
let mut columns = Vec::new();
let mut exprs = Vec::new();
for assignment in &update.assignments {
let col_name = match &assignment.target {
AssignmentTarget::ColumnName(name) => name
.0
.last()
.and_then(|p| p.as_ident())
.map(|id| id.value.clone())
.ok_or_else(|| {
DataFusionError::Plan(format!("Invalid column name in SET: {name}"))
})?,
AssignmentTarget::Tuple(_) => {
return Err(DataFusionError::Plan(
"Tuple assignment in UPDATE SET is not supported".to_string(),
));
}
};
columns.push(col_name);
exprs.push(assignment.value.to_string());
}
Ok((columns, exprs))
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
use datafusion::arrow::array::{Int32Array, StringArray, UInt64Array};
use datafusion::sql::sqlparser::dialect::GenericDialect;
use datafusion::sql::sqlparser::parser::Parser;
use paimon::catalog::{Catalog, Identifier};
use paimon::io::FileIOBuilder;
use paimon::spec::{DataType, IntType, Schema as PaimonSchema, TableSchema};
use paimon::{CatalogOptions, FileSystemCatalog, Options};
use tempfile::TempDir;
async fn setup_sql_context() -> (TempDir, SQLContext, Arc<FileSystemCatalog>) {
let temp_dir = TempDir::new().unwrap();
let warehouse = format!("file://{}", temp_dir.path().display());
let mut options = Options::new();
options.set(CatalogOptions::WAREHOUSE, warehouse);
let catalog = Arc::new(FileSystemCatalog::new(options).unwrap());
let mut sql_context = SQLContext::new();
sql_context
.register_catalog("paimon", catalog.clone())
.await
.unwrap();
sql_context
.sql("CREATE SCHEMA paimon.test_db")
.await
.unwrap();
(temp_dir, sql_context, catalog)
}
async fn setup_data_evolution_table(name: &str) -> (TempDir, SQLContext, Table) {
let (tmp, sql_context, catalog) = setup_sql_context().await;
sql_context
.sql(&format!(
"CREATE TABLE paimon.test_db.{name} (id INT, name VARCHAR, value INT) WITH ('row-tracking.enabled' = 'true', 'data-evolution.enabled' = 'true')"
))
.await
.unwrap();
sql_context
.sql(&format!(
"INSERT INTO paimon.test_db.{name} (id, name, value) VALUES (1, 'alice', 10), (2, 'bob', 20), (3, 'charlie', 30)"
))
.await
.unwrap()
.collect()
.await
.unwrap();
let table = catalog
.get_table(&Identifier::new("test_db", name))
.await
.unwrap();
(tmp, sql_context, table)
}
fn parse_update(sql: &str) -> Update {
let dialect = GenericDialect {};
let stmts = Parser::parse_sql(&dialect, sql).unwrap();
match stmts.into_iter().next().unwrap() {
datafusion::sql::sqlparser::ast::Statement::Update(u) => u,
_ => panic!("Expected UPDATE statement"),
}
}
fn collect_rows(batches: &[datafusion::arrow::array::RecordBatch]) -> Vec<(i32, String, i32)> {
let mut rows = Vec::new();
for batch in batches {
let ids = batch
.column(0)
.as_any()
.downcast_ref::<Int32Array>()
.unwrap();
let names = batch
.column(1)
.as_any()
.downcast_ref::<StringArray>()
.unwrap();
let values = batch
.column(2)
.as_any()
.downcast_ref::<Int32Array>()
.unwrap();
for i in 0..batch.num_rows() {
rows.push((ids.value(i), names.value(i).to_string(), values.value(i)));
}
}
rows
}
#[tokio::test]
async fn test_update_with_where() {
let (_tmp, sql_context, table) = setup_data_evolution_table("t_with_where").await;
let update =
parse_update("UPDATE paimon.test_db.t_with_where SET name = 'ALICE' WHERE id = 1");
execute_update(&sql_context, &update, table).await.unwrap();
let batches = sql_context
.sql("SELECT id, name, value FROM paimon.test_db.t_with_where 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),
]
);
}
#[tokio::test]
async fn test_update_without_where() {
let (_tmp, sql_context, table) = setup_data_evolution_table("t_without_where").await;
let update = parse_update("UPDATE paimon.test_db.t_without_where SET value = 99");
execute_update(&sql_context, &update, table).await.unwrap();
let batches = sql_context
.sql("SELECT id, name, value FROM paimon.test_db.t_without_where ORDER BY id")
.await
.unwrap()
.collect()
.await
.unwrap();
let rows = collect_rows(&batches);
assert_eq!(
rows,
vec![
(1, "alice".to_string(), 99),
(2, "bob".to_string(), 99),
(3, "charlie".to_string(), 99),
]
);
}
#[tokio::test]
async fn test_update_multiple_columns() {
let (_tmp, sql_context, table) = setup_data_evolution_table("t_multi_col").await;
let update = parse_update(
"UPDATE paimon.test_db.t_multi_col SET name = 'updated', value = 0 WHERE id = 2",
);
execute_update(&sql_context, &update, table).await.unwrap();
let batches = sql_context
.sql("SELECT id, name, value FROM paimon.test_db.t_multi_col ORDER BY id")
.await
.unwrap()
.collect()
.await
.unwrap();
let rows = collect_rows(&batches);
assert_eq!(
rows,
vec![
(1, "alice".to_string(), 10),
(2, "updated".to_string(), 0),
(3, "charlie".to_string(), 30),
]
);
}
#[tokio::test]
async fn test_update_no_matching_rows() {
let (_tmp, sql_context, table) = setup_data_evolution_table("t_no_match").await;
let update =
parse_update("UPDATE paimon.test_db.t_no_match SET name = 'nobody' WHERE id = 99");
let result = execute_update(&sql_context, &update, table).await.unwrap();
let batches = result.collect().await.unwrap();
let count = batches[0]
.column(0)
.as_any()
.downcast_ref::<UInt64Array>()
.unwrap()
.value(0);
assert_eq!(count, 0);
}
#[tokio::test]
async fn test_update_row_id_stability() {
let (_tmp, sql_context, table) = setup_data_evolution_table("t_row_id").await;
// Get row IDs before update
let before = sql_context
.sql("SELECT id, \"_ROW_ID\" FROM paimon.test_db.t_row_id ORDER BY id")
.await
.unwrap()
.collect()
.await
.unwrap();
let update = parse_update("UPDATE paimon.test_db.t_row_id SET name = 'ALICE' WHERE id = 1");
execute_update(&sql_context, &update, table).await.unwrap();
// Get row IDs after update
let after = sql_context
.sql("SELECT id, \"_ROW_ID\" FROM paimon.test_db.t_row_id ORDER BY id")
.await
.unwrap()
.collect()
.await
.unwrap();
// Row IDs should remain the same
assert_eq!(before, after);
}
#[tokio::test]
async fn test_update_rejects_pk_table_without_data_evolution() {
let file_io = FileIOBuilder::new("memory").build().unwrap();
let table_path = "memory:/test_update_reject";
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()))
.primary_key(["id"])
.option("bucket", "1")
.build()
.unwrap();
let table_schema = TableSchema::new(0, &schema);
let table = Table::new(
file_io,
Identifier::new("default", "t"),
table_path.to_string(),
table_schema,
None,
);
let sql_context = SQLContext::new();
let update = parse_update("UPDATE t SET id = 1");
let result = execute_update(&sql_context, &update, table).await;
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("primary-key tables without data-evolution"));
}
// -----------------------------------------------------------------------
// CoW UPDATE tests (append-only tables)
// -----------------------------------------------------------------------
async fn setup_append_only_table(name: &str) -> (TempDir, SQLContext) {
let (tmp, sql_context, _catalog) = setup_sql_context().await;
sql_context
.sql(&format!(
"CREATE TABLE paimon.test_db.{name} (id INT, name VARCHAR, value INT)"
))
.await
.unwrap();
sql_context
.sql(&format!(
"INSERT INTO paimon.test_db.{name} (id, name, value) VALUES (1, 'alice', 10), (2, 'bob', 20), (3, 'charlie', 30)"
))
.await
.unwrap()
.collect()
.await
.unwrap();
(tmp, sql_context)
}
async fn query_rows(sql_context: &SQLContext, table: &str) -> Vec<(i32, String, i32)> {
let batches = sql_context
.sql(&format!("SELECT id, name, value FROM {table} ORDER BY id"))
.await
.unwrap()
.collect()
.await
.unwrap();
collect_rows(&batches)
}
#[tokio::test]
async fn test_cow_update_with_where() {
let (_tmp, sql_context) = setup_append_only_table("t_cow_where").await;
sql_context
.sql("UPDATE paimon.test_db.t_cow_where SET name = 'ALICE' WHERE id = 1")
.await
.unwrap();
let rows = query_rows(&sql_context, "paimon.test_db.t_cow_where").await;
assert_eq!(
rows,
vec![
(1, "ALICE".to_string(), 10),
(2, "bob".to_string(), 20),
(3, "charlie".to_string(), 30),
]
);
}
#[tokio::test]
async fn test_cow_update_without_where() {
let (_tmp, sql_context) = setup_append_only_table("t_cow_no_where").await;
sql_context
.sql("UPDATE paimon.test_db.t_cow_no_where SET value = 99")
.await
.unwrap();
let rows = query_rows(&sql_context, "paimon.test_db.t_cow_no_where").await;
assert_eq!(
rows,
vec![
(1, "alice".to_string(), 99),
(2, "bob".to_string(), 99),
(3, "charlie".to_string(), 99),
]
);
}
#[tokio::test]
async fn test_cow_update_multiple_columns() {
let (_tmp, sql_context) = setup_append_only_table("t_cow_multi").await;
sql_context
.sql("UPDATE paimon.test_db.t_cow_multi SET name = 'updated', value = 0 WHERE id = 2")
.await
.unwrap();
let rows = query_rows(&sql_context, "paimon.test_db.t_cow_multi").await;
assert_eq!(
rows,
vec![
(1, "alice".to_string(), 10),
(2, "updated".to_string(), 0),
(3, "charlie".to_string(), 30),
]
);
}
#[tokio::test]
async fn test_cow_update_no_matching_rows() {
let (_tmp, sql_context) = setup_append_only_table("t_cow_nomatch").await;
let result = sql_context
.sql("UPDATE paimon.test_db.t_cow_nomatch SET name = 'nobody' WHERE id = 99")
.await
.unwrap()
.collect()
.await
.unwrap();
let count = result[0]
.column(0)
.as_any()
.downcast_ref::<UInt64Array>()
.unwrap()
.value(0);
assert_eq!(count, 0);
}
#[tokio::test]
async fn test_cow_update_expression() {
let (_tmp, sql_context) = setup_append_only_table("t_cow_expr").await;
sql_context
.sql("UPDATE paimon.test_db.t_cow_expr SET value = value + 100 WHERE id >= 2")
.await
.unwrap();
let rows = query_rows(&sql_context, "paimon.test_db.t_cow_expr").await;
assert_eq!(
rows,
vec![
(1, "alice".to_string(), 10),
(2, "bob".to_string(), 120),
(3, "charlie".to_string(), 130),
]
);
}
}