blob: 3e1231661df24e15548ef326af1007094ffc83bf [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.
//! Row-ID-based update writer for data evolution tables.
//!
//! [`DataEvolutionWriter`] accepts rows to update (identified by `_ROW_ID`) along with
//! new column values, then handles file metadata lookup, row grouping,
//! reading original columns, applying updates, and writing partial-column files.
//!
//! The writer does NOT commit — it returns `Vec<CommitMessage>` that the caller
//! passes to [`TableCommit`](super::TableCommit).
//! This separation allows callers to compose multiple operations into a single commit,
//! similar to Iceberg's Transaction/Action pattern.
use crate::io::FileIO;
use crate::spec::{BinaryRow, CoreOptions, DataField, DataFileMeta, PartitionComputer};
use crate::table::commit_message::CommitMessage;
use crate::table::data_file_writer::DataFileWriter;
use crate::table::stats_filter::group_by_overlapping_row_id;
use crate::table::DataSplitBuilder;
use crate::table::Table;
use crate::Result;
use arrow_array::{Array, ArrayRef, Int64Array, RecordBatch};
use arrow_select::concat::concat_batches;
use arrow_select::interleave::interleave;
use futures::TryStreamExt;
use std::collections::{HashMap, HashSet};
/// Engine-agnostic writer for partial-column updates via `_ROW_ID`.
///
/// Usage:
/// 1. Create via [`DataEvolutionWriter::new`] (validates preconditions).
/// 2. Feed matched rows via [`add_matched_batch`](Self::add_matched_batch).
/// Each batch must contain a non-null `_ROW_ID` (Int64) column plus the update columns.
/// 3. Call [`prepare_commit`](Self::prepare_commit) to produce `CommitMessage`s.
/// 4. Commit via [`TableCommit`](super::TableCommit) (caller's responsibility).
///
/// The query engine (DataFusion, custom, etc.) is responsible for:
/// - Parsing the MERGE INTO SQL
/// - Executing the JOIN to find matched rows
/// - Computing new column values
/// - Passing the results as `RecordBatch`es with `_ROW_ID` + update columns
#[must_use = "writer must be used to call prepare_commit()"]
pub struct DataEvolutionWriter {
table: Table,
update_columns: Vec<String>,
matched_batches: Vec<RecordBatch>,
}
impl DataEvolutionWriter {
/// Create a new writer for the given table and update columns.
///
/// Validates:
/// - `data-evolution.enabled = true`
/// - `row-tracking.enabled = true`
/// - No primary keys
/// - Update columns don't include partition keys
pub fn new(table: &Table, update_columns: Vec<String>) -> Result<Self> {
let schema = table.schema();
let core_options = CoreOptions::new(schema.options());
if !core_options.data_evolution_enabled() {
return Err(crate::Error::Unsupported {
message:
"MERGE INTO is only supported for tables with 'data-evolution.enabled' = 'true'"
.to_string(),
});
}
if !core_options.row_tracking_enabled() {
return Err(crate::Error::Unsupported {
message: "MERGE INTO requires 'row-tracking.enabled' = 'true'".to_string(),
});
}
if !schema.trimmed_primary_keys().is_empty() {
return Err(crate::Error::Unsupported {
message: "MERGE INTO on data evolution tables does not support primary keys"
.to_string(),
});
}
let partition_keys = schema.partition_keys();
let blob_descriptor_fields = core_options.blob_descriptor_fields();
for col in &update_columns {
if partition_keys.contains(col) {
return Err(crate::Error::Unsupported {
message: format!("Cannot update partition column '{col}' in MERGE INTO"),
});
}
if let Some(field) = schema.fields().iter().find(|f| f.name() == col) {
if field.data_type().is_blob_type() && !blob_descriptor_fields.contains(col) {
return Err(crate::Error::Unsupported {
message: format!(
"Cannot update raw-data BLOB column '{col}' in MERGE INTO. \
Only BLOB columns listed in 'blob-descriptor-field' can be updated"
),
});
}
}
}
Ok(Self {
table: table.clone(),
update_columns,
matched_batches: Vec::new(),
})
}
/// Add a batch of matched rows.
///
/// The batch must contain:
/// - A non-null `_ROW_ID` column (Int64) identifying which rows to update
/// - One column for each entry in `update_columns` with the new values
pub fn add_matched_batch(&mut self, batch: RecordBatch) -> Result<()> {
if batch.num_rows() == 0 {
return Ok(());
}
let row_id_col = row_id_column(&batch)?;
validate_row_id_not_null(row_id_col)?;
validate_update_columns(&batch, &self.update_columns)?;
self.matched_batches.push(batch);
Ok(())
}
/// Scan file metadata, group matched rows by file, read originals,
/// apply updates, and write partial-column files.
///
/// Returns `CommitMessage`s for the caller to commit via [`TableCommit`](super::TableCommit).
#[must_use = "commit messages must be passed to TableCommit"]
pub async fn prepare_commit(self) -> Result<Vec<CommitMessage>> {
let total_matched: usize = self.matched_batches.iter().map(|b| b.num_rows()).sum();
if total_matched == 0 {
return Ok(Vec::new());
}
// 1. Scan file metadata and build row_id -> file group index.
// In data-evolution tables, multiple files can share the same first_row_id
// (base file + partial-column files). We must group them so the reader
// can merge columns correctly.
let scan = self.table.new_read_builder().new_scan();
let plan = scan.plan().await?;
let mut file_index: Vec<FileRowRange> = Vec::new();
for split in plan.splits() {
let partition_bytes = split.partition().to_serialized_bytes();
let bucket = split.bucket();
let bucket_path = split.bucket_path().to_string();
let snapshot_id = split.snapshot_id();
let total_buckets = split.total_buckets();
let all_files: Vec<DataFileMeta> = split
.data_files()
.iter()
.filter(|f| f.first_row_id.is_some())
.cloned()
.collect();
let groups = group_by_overlapping_row_id(all_files);
for group in groups {
// Compute the overall row_id range for this group.
// The base file has the widest range; partial-column files share it.
let first_row_id = group.iter().filter_map(|f| f.first_row_id).min().unwrap();
let last_row_id = group
.iter()
.filter_map(|f| f.row_id_range().map(|(_, end)| end))
.max()
.unwrap();
// The actual row count is the max among the group (base file's count).
let row_count = group.iter().map(|f| f.row_count).max().unwrap();
file_index.push(FileRowRange {
first_row_id,
last_row_id,
row_count,
partition: partition_bytes.clone(),
bucket,
bucket_path: bucket_path.clone(),
snapshot_id,
total_buckets,
files: group,
});
}
}
file_index.sort_by_key(|f| f.first_row_id);
if file_index.is_empty() {
return Err(crate::Error::DataInvalid {
message: "No files with row tracking found in target table".to_string(),
source: None,
});
}
// 2. Group matched rows by their owning file
let file_matches = group_matched_rows_by_file(&self.matched_batches, &file_index)?;
// 3. For each affected file: read original columns, apply updates, write partial files
let mut writer = DataEvolutionPartialWriter::new(&self.table, self.update_columns.clone())?;
for (&file_pos, matched_rows) in &file_matches {
let file_range = &file_index[file_pos];
let first_row_id = file_range.first_row_id;
let row_count = file_range.row_count as usize;
// Read original columns from the entire file group (base + partial-column files).
let col_refs: Vec<&str> = self.update_columns.iter().map(|s| s.as_str()).collect();
let mut rb = self.table.new_read_builder();
rb.with_projection(&col_refs);
let read = rb.new_read()?;
// Base + partial-column files share row-id ranges, so physical
// row counts overcount the group's logical rows.
let split = DataSplitBuilder::new()
.with_snapshot(file_range.snapshot_id)
.with_partition(BinaryRow::from_serialized_bytes(&file_range.partition)?)
.with_bucket(file_range.bucket)
.with_bucket_path(file_range.bucket_path.clone())
.with_total_buckets(file_range.total_buckets)
.with_data_files(file_range.files.clone())
.with_raw_convertible(file_range.files.len() == 1)
.build()?;
let stream = read.to_arrow(&[split])?;
let original_batches: Vec<RecordBatch> = stream.try_collect().await?;
let original_batch = if original_batches.is_empty() {
continue;
} else if original_batches.len() == 1 {
original_batches.into_iter().next().unwrap()
} else {
concat_batches(&original_batches[0].schema(), &original_batches).map_err(|e| {
crate::Error::DataInvalid {
message: format!("Failed to concat batches: {e}"),
source: None,
}
})?
};
if original_batch.num_rows() != row_count {
return Err(crate::Error::DataInvalid {
message: format!(
"Expected {} rows from file, got {}",
row_count,
original_batch.num_rows()
),
source: None,
});
}
// Apply updates using 2-array interleave: [original_col, updates_col].
// Matched rows are gathered into a single contiguous update array first,
// avoiding O(N) array clones for every row in the file.
let mut new_columns: Vec<ArrayRef> = Vec::with_capacity(self.update_columns.len());
// Sort matched rows by offset for contiguous iteration
let mut sorted_matches: Vec<(usize, usize, usize)> = matched_rows
.iter()
.map(|m| (m.offset, m.batch_idx, m.row_idx))
.collect();
sorted_matches.sort_by_key(|(offset, _, _)| *offset);
for (col_idx, col_name) in self.update_columns.iter().enumerate() {
let original_col = original_batch.column(col_idx);
let original_dtype = original_col.data_type();
// Gather update values into a single array (one entry per matched row, in offset order)
let update_indices: Vec<(usize, usize)> = sorted_matches
.iter()
.map(|&(_, batch_idx, row_idx)| (batch_idx, row_idx))
.collect();
// Collect unique batch arrays, cast if needed
let mut batch_arrays: Vec<ArrayRef> = Vec::new();
let mut batch_id_map: HashMap<usize, usize> = HashMap::new();
let mut interleave_src: Vec<(usize, usize)> =
Vec::with_capacity(update_indices.len());
for &(batch_idx, row_idx) in &update_indices {
let arr_idx = match batch_id_map.get(&batch_idx) {
Some(&idx) => idx,
None => {
let src_col =
matched_column(&self.matched_batches[batch_idx], col_name)?;
let casted = if src_col.data_type() != original_dtype {
arrow_cast::cast(src_col.as_ref(), original_dtype).map_err(|e| {
crate::Error::DataInvalid {
message: format!("Failed to cast column {col_name}: {e}"),
source: None,
}
})?
} else {
src_col
};
let idx = batch_arrays.len();
batch_arrays.push(casted);
batch_id_map.insert(batch_idx, idx);
idx
}
};
interleave_src.push((arr_idx, row_idx));
}
let update_col = if batch_arrays.len() == 1 && interleave_src.len() == 1 {
// Single update value — just slice
let (_, row_idx) = interleave_src[0];
batch_arrays[0].slice(row_idx, 1)
} else {
let refs: Vec<&dyn Array> = batch_arrays.iter().map(|a| a.as_ref()).collect();
interleave(&refs, &interleave_src).map_err(|e| crate::Error::DataInvalid {
message: format!("Failed to gather update values for {col_name}: {e}"),
source: None,
})?
};
// Build final indices: 2 sources — [0] = original, [1] = update_col
let mut indices: Vec<(usize, usize)> = Vec::with_capacity(row_count);
let mut match_pos = 0;
for row in 0..row_count {
if match_pos < sorted_matches.len() && sorted_matches[match_pos].0 == row {
indices.push((1, match_pos));
match_pos += 1;
} else {
indices.push((0, row));
}
}
let sources: [&dyn Array; 2] = [original_col.as_ref(), update_col.as_ref()];
let new_col =
interleave(&sources, &indices).map_err(|e| crate::Error::DataInvalid {
message: format!("Failed to interleave column {col_name}: {e}"),
source: None,
})?;
new_columns.push(new_col);
}
let updated_batch = RecordBatch::try_new(original_batch.schema(), new_columns)
.map_err(|e| crate::Error::DataInvalid {
message: format!("Failed to create updated batch: {e}"),
source: None,
})?;
writer
.write_partial_batch(
file_range.partition.clone(),
file_range.bucket,
first_row_id,
file_range.snapshot_id,
updated_batch,
)
.await?;
}
// 4. Collect commit messages (caller is responsible for committing)
writer.prepare_commit().await
}
}
/// Binary search for the file that owns a given row_id.
fn find_owning_file(file_index: &[FileRowRange], row_id: i64) -> Option<(usize, &FileRowRange)> {
let pos = file_index.partition_point(|f| f.first_row_id <= row_id);
if pos == 0 {
return None;
}
let idx = pos - 1;
let candidate = &file_index[idx];
if row_id <= candidate.last_row_id {
Some((idx, candidate))
} else {
None
}
}
fn group_matched_rows_by_file(
matched_batches: &[RecordBatch],
file_index: &[FileRowRange],
) -> Result<HashMap<usize, Vec<MatchedRow>>> {
let mut file_matches: HashMap<usize, Vec<MatchedRow>> = HashMap::new();
let mut seen_updates: HashSet<(usize, usize)> = HashSet::new();
for (batch_idx, batch) in matched_batches.iter().enumerate() {
let row_id_col = row_id_column(batch)?;
validate_row_id_not_null(row_id_col)?;
for row_idx in 0..batch.num_rows() {
let row_id = row_id_col.value(row_idx);
let (file_pos, file_range) =
find_owning_file(file_index, row_id).ok_or_else(|| crate::Error::DataInvalid {
message: format!("No file found for _ROW_ID {row_id}"),
source: None,
})?;
let offset = (row_id - file_range.first_row_id) as usize;
if !seen_updates.insert((file_pos, offset)) {
return Err(crate::Error::DataInvalid {
message: format!(
"row_offset {offset} has duplicate UPDATE operations; \
this may indicate a many-to-many join in the MERGE source"
),
source: None,
});
}
file_matches.entry(file_pos).or_default().push(MatchedRow {
offset,
batch_idx,
row_idx,
});
}
}
Ok(file_matches)
}
fn row_id_column(batch: &RecordBatch) -> Result<&Int64Array> {
batch
.column_by_name("_ROW_ID")
.ok_or_else(|| crate::Error::DataInvalid {
message: "Matched batch must contain a '_ROW_ID' column".to_string(),
source: None,
})?
.as_any()
.downcast_ref::<Int64Array>()
.ok_or_else(|| crate::Error::DataInvalid {
message: "_ROW_ID column must be Int64".to_string(),
source: None,
})
}
fn validate_row_id_not_null(row_id_col: &Int64Array) -> Result<()> {
if row_id_col.null_count() == 0 {
return Ok(());
}
for row_idx in 0..row_id_col.len() {
if row_id_col.is_null(row_idx) {
return Err(crate::Error::DataInvalid {
message: format!("_ROW_ID must not be null at matched row {row_idx}"),
source: None,
});
}
}
Ok(())
}
fn validate_update_columns(batch: &RecordBatch, update_columns: &[String]) -> Result<()> {
for col in update_columns {
matched_column_index(batch, col)?;
}
Ok(())
}
fn matched_column_index(batch: &RecordBatch, col: &str) -> Result<usize> {
batch
.schema()
.index_of(col)
.map_err(|e| crate::Error::DataInvalid {
message: format!("Column {col} not found in matched batch: {e}"),
source: None,
})
}
fn matched_column(batch: &RecordBatch, col: &str) -> Result<ArrayRef> {
let idx = matched_column_index(batch, col)?;
Ok(batch.column(idx).clone())
}
struct FileRowRange {
first_row_id: i64,
last_row_id: i64,
row_count: i64,
partition: Vec<u8>,
bucket: i32,
bucket_path: String,
snapshot_id: i64,
total_buckets: i32,
/// All files in this row-id group (base file + partial-column files).
files: Vec<DataFileMeta>,
}
struct MatchedRow {
offset: usize,
batch_idx: usize,
row_idx: usize,
}
// ---------------------------------------------------------------------------
// DataEvolutionPartialWriter — writes partial-column parquet files for data evolution
// ---------------------------------------------------------------------------
/// Key: (partition_bytes, bucket, first_row_id)
type WriterKey = (Vec<u8>, i32, i64);
type PartialCommitGroup = (Option<i64>, Vec<DataFileMeta>);
/// Writer for data evolution partial-column files.
///
/// Unlike [`TableWrite`](super::TableWrite) which writes full-row files for append-only tables,
/// `DataEvolutionPartialWriter` writes partial-column files used by MERGE INTO on data evolution tables.
/// Each output file contains only the updated columns and shares the same `first_row_id` range
/// as the original file, allowing the reader to merge columns at read time.
///
/// Produces parquet files containing only the specified `write_columns`, with
/// `file_source = APPEND (0)`, caller-supplied `first_row_id`, and `write_cols`.
pub(crate) struct DataEvolutionPartialWriter {
file_io: FileIO,
table_location: String,
partition_computer: PartitionComputer,
partition_keys: Vec<String>,
schema_id: i64,
target_file_size: i64,
file_compression: String,
file_compression_zstd_level: i32,
write_buffer_size: i64,
file_format: String,
write_fields: Vec<DataField>,
write_columns: Vec<String>,
/// Writers keyed by (partition_bytes, bucket, first_row_id).
writers: HashMap<WriterKey, DataFileWriter>,
check_from_snapshots: HashMap<WriterKey, i64>,
}
impl DataEvolutionPartialWriter {
/// Create a new writer for partial-column data evolution files.
///
/// `write_columns` specifies which table columns this write covers (the SET targets).
pub fn new(table: &Table, write_columns: Vec<String>) -> Result<Self> {
let schema = table.schema();
let core_options = CoreOptions::new(schema.options());
if !core_options.data_evolution_enabled() {
return Err(crate::Error::Unsupported {
message: "DataEvolutionPartialWriter requires data-evolution.enabled = true"
.to_string(),
});
}
let partition_keys: Vec<String> = schema.partition_keys().to_vec();
let fields = schema.fields();
let write_fields = write_columns
.iter()
.map(|column| {
fields
.iter()
.find(|field| field.name() == column)
.cloned()
.ok_or_else(|| crate::Error::DataInvalid {
message: format!("Unknown data-evolution write column '{column}'"),
source: None,
})
})
.collect::<Result<Vec<_>>>()?;
let partition_computer = PartitionComputer::new(
&partition_keys,
fields,
core_options.partition_default_name(),
core_options.legacy_partition_name(),
)?;
Ok(Self {
file_io: table.file_io().clone(),
table_location: table.location().to_string(),
partition_computer,
partition_keys,
schema_id: schema.id(),
target_file_size: core_options.target_file_size(),
file_compression: core_options.file_compression().to_string(),
file_compression_zstd_level: core_options.file_compression_zstd_level(),
write_buffer_size: core_options.write_parquet_buffer_size(),
file_format: core_options.file_format().to_string(),
write_fields,
write_columns,
writers: HashMap::new(),
check_from_snapshots: HashMap::new(),
})
}
/// Write a partial-column batch for a specific partition, bucket, and row ID range.
///
/// The `batch` must contain only the columns specified in `write_columns`.
/// `first_row_id` must match the original file's `first_row_id` for the affected rows.
pub async fn write_partial_batch(
&mut self,
partition_bytes: Vec<u8>,
bucket: i32,
first_row_id: i64,
check_from_snapshot: i64,
batch: RecordBatch,
) -> Result<()> {
if batch.num_rows() == 0 {
return Ok(());
}
let key = (partition_bytes.clone(), bucket, first_row_id);
self.check_from_snapshots
.entry(key.clone())
.and_modify(|snapshot| *snapshot = (*snapshot).min(check_from_snapshot))
.or_insert(check_from_snapshot);
if !self.writers.contains_key(&key) {
let partition_path = if self.partition_keys.is_empty() {
String::new()
} else {
let row = BinaryRow::from_serialized_bytes(&partition_bytes)?;
self.partition_computer.generate_partition_path(&row)?
};
let writer = DataFileWriter::new(
self.file_io.clone(),
self.table_location.clone(),
partition_path,
bucket,
self.schema_id,
self.target_file_size,
self.file_compression.clone(),
self.file_compression_zstd_level,
self.write_buffer_size,
self.file_format.clone(),
self.write_fields.clone(),
Some(0), // file_source: APPEND
Some(first_row_id),
Some(self.write_columns.clone()),
);
self.writers.insert(key.clone(), writer);
}
let writer = self.writers.get_mut(&key).unwrap();
writer.write(&batch).await
}
/// Close all writers and collect CommitMessages for use with TableCommit.
pub async fn prepare_commit(&mut self) -> Result<Vec<CommitMessage>> {
let writers: Vec<(WriterKey, DataFileWriter)> = self.writers.drain().collect();
let mut check_from_snapshots = std::mem::take(&mut self.check_from_snapshots);
let futures: Vec<_> = writers
.into_iter()
.map(|(key, mut writer)| {
let check_from_snapshot = check_from_snapshots.remove(&key);
async move {
let files = writer.prepare_commit().await?;
let (partition_bytes, bucket, _first_row_id) = key;
Ok::<_, crate::Error>((partition_bytes, bucket, check_from_snapshot, files))
}
})
.collect();
let results = futures::future::try_join_all(futures).await?;
// Group files by (partition, bucket) since multiple first_row_ids may share the same partition/bucket
let mut grouped: HashMap<(Vec<u8>, i32), PartialCommitGroup> = HashMap::new();
for (partition_bytes, bucket, check_from_snapshot, files) in results {
let entry = grouped
.entry((partition_bytes, bucket))
.or_insert_with(|| (None, Vec::new()));
if let Some(check_from_snapshot) = check_from_snapshot {
entry.0 = Some(entry.0.map_or(check_from_snapshot, |snapshot| {
snapshot.min(check_from_snapshot)
}));
}
entry.1.extend(files);
}
let mut messages = Vec::new();
for ((partition_bytes, bucket), (check_from_snapshot, files)) in grouped {
if !files.is_empty() {
let mut message = CommitMessage::new(partition_bytes, bucket, files);
message.check_from_snapshot = check_from_snapshot;
messages.push(message);
}
}
Ok(messages)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::catalog::Identifier;
use crate::io::FileIOBuilder;
use crate::spec::{DataType, IntType, Schema, TableSchema, VarCharType};
use arrow_array::StringArray;
use arrow_schema::{DataType as ArrowDataType, Field as ArrowField, Schema as ArrowSchema};
use std::sync::Arc;
fn test_file_io() -> FileIO {
FileIOBuilder::new("memory").build().unwrap()
}
fn make_test_file_meta(
file_name: &str,
row_count: i64,
first_row_id: Option<i64>,
max_seq: i64,
write_cols: Option<Vec<String>>,
) -> DataFileMeta {
use crate::spec::stats::BinaryTableStats;
let empty_stats = BinaryTableStats::empty();
DataFileMeta {
file_name: file_name.to_string(),
file_size: 0,
row_count,
min_key: vec![],
max_key: vec![],
key_stats: empty_stats.clone(),
value_stats: empty_stats,
min_sequence_number: 0,
max_sequence_number: max_seq,
schema_id: 0,
level: 0,
extra_files: vec![],
creation_time: None,
delete_row_count: None,
embedded_index: None,
file_source: Some(0),
value_stats_cols: None,
external_path: None,
first_row_id,
write_cols,
}
}
fn test_data_evolution_schema() -> TableSchema {
let schema = Schema::builder()
.column("id", DataType::Int(IntType::new()))
.column("name", DataType::VarChar(VarCharType::string_type()))
.column("value", DataType::Int(IntType::new()))
.option("data-evolution.enabled", "true")
.option("row-tracking.enabled", "true")
.build()
.unwrap();
TableSchema::new(0, &schema)
}
fn test_table(file_io: &FileIO, table_path: &str) -> Table {
Table::new(
file_io.clone(),
Identifier::new("default", "test_de_table"),
table_path.to_string(),
test_data_evolution_schema(),
None,
)
}
async fn setup_dirs(file_io: &FileIO, table_path: &str) {
file_io
.mkdirs(&format!("{table_path}/snapshot/"))
.await
.unwrap();
file_io
.mkdirs(&format!("{table_path}/manifest/"))
.await
.unwrap();
}
fn make_partial_batch(names: Vec<&str>) -> RecordBatch {
let schema = Arc::new(ArrowSchema::new(vec![ArrowField::new(
"name",
ArrowDataType::Utf8,
true,
)]));
RecordBatch::try_new(schema, vec![Arc::new(StringArray::from(names))]).unwrap()
}
fn make_matched_batch(row_ids: Vec<Option<i64>>, names: Vec<&str>) -> RecordBatch {
let schema = Arc::new(ArrowSchema::new(vec![
ArrowField::new("_ROW_ID", ArrowDataType::Int64, true),
ArrowField::new("name", ArrowDataType::Utf8, true),
]));
RecordBatch::try_new(
schema,
vec![
Arc::new(Int64Array::from(row_ids)),
Arc::new(StringArray::from(names)),
],
)
.unwrap()
}
fn make_matched_batch_with_schema(
fields: Vec<ArrowField>,
columns: Vec<ArrayRef>,
) -> RecordBatch {
RecordBatch::try_new(Arc::new(ArrowSchema::new(fields)), columns).unwrap()
}
fn test_file_index() -> Vec<FileRowRange> {
vec![FileRowRange {
first_row_id: 10,
last_row_id: 12,
row_count: 3,
partition: vec![],
bucket: 0,
bucket_path: String::new(),
snapshot_id: 1,
total_buckets: 1,
files: vec![make_test_file_meta("base.parquet", 3, Some(10), 1, None)],
}]
}
#[tokio::test]
async fn test_write_partial_column_file() {
let file_io = test_file_io();
let table_path = "memory:/test_de_write";
setup_dirs(&file_io, table_path).await;
let table = test_table(&file_io, table_path);
let mut writer = DataEvolutionPartialWriter::new(&table, vec!["name".to_string()]).unwrap();
let batch = make_partial_batch(vec!["alice", "bob", "charlie"]);
writer
.write_partial_batch(vec![], 0, 0, 7, batch)
.await
.unwrap();
let messages = writer.prepare_commit().await.unwrap();
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].new_files.len(), 1);
assert_eq!(messages[0].check_from_snapshot, Some(7));
let meta = &messages[0].new_files[0];
assert_eq!(meta.row_count, 3);
assert_eq!(meta.first_row_id, Some(0));
assert_eq!(meta.write_cols, Some(vec!["name".to_string()]));
assert_eq!(meta.file_source, Some(0));
}
#[tokio::test]
async fn test_different_first_row_id_creates_separate_files() {
let file_io = test_file_io();
let table_path = "memory:/test_de_write_multi";
setup_dirs(&file_io, table_path).await;
let table = test_table(&file_io, table_path);
let mut writer = DataEvolutionPartialWriter::new(&table, vec!["name".to_string()]).unwrap();
// Two batches with different first_row_id should produce two files
let batch1 = make_partial_batch(vec!["alice", "bob"]);
writer
.write_partial_batch(vec![], 0, 0, 9, batch1)
.await
.unwrap();
let batch2 = make_partial_batch(vec!["charlie"]);
writer
.write_partial_batch(vec![], 0, 100, 8, batch2)
.await
.unwrap();
let messages = writer.prepare_commit().await.unwrap();
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].new_files.len(), 2);
assert_eq!(messages[0].check_from_snapshot, Some(8));
let mut files = messages[0].new_files.clone();
files.sort_by_key(|f| f.first_row_id);
assert_eq!(files[0].first_row_id, Some(0));
assert_eq!(files[0].row_count, 2);
assert_eq!(files[1].first_row_id, Some(100));
assert_eq!(files[1].row_count, 1);
}
#[test]
fn test_add_matched_batch_rejects_null_row_id() {
let file_io = test_file_io();
let table = test_table(&file_io, "memory:/test_de_add_batch");
let mut writer = DataEvolutionWriter::new(&table, vec!["name".to_string()]).unwrap();
let err = writer
.add_matched_batch(make_matched_batch(
vec![Some(10), None],
vec!["alice", "bob"],
))
.err()
.unwrap();
assert!(
matches!(err, crate::Error::DataInvalid { message, .. } if message.contains("_ROW_ID must not be null"))
);
}
#[test]
fn test_add_matched_batch_rejects_missing_update_column() {
let file_io = test_file_io();
let table = test_table(&file_io, "memory:/test_de_add_batch_missing_col");
let mut writer = DataEvolutionWriter::new(&table, vec!["value".to_string()]).unwrap();
let err = writer
.add_matched_batch(make_matched_batch(vec![Some(10)], vec!["alice"]))
.err()
.unwrap();
assert!(
matches!(err, crate::Error::DataInvalid { message, .. } if message.contains("Column value not found"))
);
}
#[test]
fn test_group_matched_rows_rejects_null_row_id() {
let batches = vec![make_matched_batch(
vec![Some(10), None],
vec!["alice", "bob"],
)];
let err = group_matched_rows_by_file(&batches, &test_file_index())
.err()
.unwrap();
assert!(
matches!(err, crate::Error::DataInvalid { message, .. } if message.contains("_ROW_ID must not be null"))
);
}
#[test]
fn test_group_matched_rows_rejects_duplicate_update() {
let batches = vec![make_matched_batch(
vec![Some(10), Some(10)],
vec!["alice", "ALICE"],
)];
let err = group_matched_rows_by_file(&batches, &test_file_index())
.err()
.unwrap();
assert!(
matches!(err, crate::Error::DataInvalid { message, .. } if message.contains("duplicate UPDATE"))
);
}
#[test]
fn test_group_matched_rows_rejects_duplicate_update_across_batches() {
let batches = vec![
make_matched_batch(vec![Some(10)], vec!["alice"]),
make_matched_batch(vec![Some(10)], vec!["ALICE"]),
];
let err = group_matched_rows_by_file(&batches, &test_file_index())
.err()
.unwrap();
assert!(
matches!(err, crate::Error::DataInvalid { message, .. } if message.contains("duplicate UPDATE"))
);
}
#[test]
fn test_matched_column_uses_batch_schema() {
let batch = make_matched_batch_with_schema(
vec![
ArrowField::new("value", ArrowDataType::Int32, true),
ArrowField::new("name", ArrowDataType::Utf8, true),
],
vec![
Arc::new(arrow_array::Int32Array::from(vec![20])),
Arc::new(StringArray::from(vec!["bob"])),
],
);
let column = matched_column(&batch, "name").unwrap();
let names = column.as_any().downcast_ref::<StringArray>().unwrap();
assert_eq!(names.value(0), "bob");
}
#[test]
fn test_find_owning_file_with_grouped_ranges() {
// Simulate a file group: base file (3 cols, 100 rows) + partial file (1 col, 100 rows)
// sharing the same first_row_id range [0, 99].
let base_file = make_test_file_meta("base-0.parquet", 100, Some(0), 1, None);
let partial_file = make_test_file_meta(
"partial-0.parquet",
100,
Some(0),
2,
Some(vec!["name".to_string()]),
);
let file_index = vec![
FileRowRange {
first_row_id: 0,
last_row_id: 99,
row_count: 100,
partition: vec![],
bucket: 0,
bucket_path: String::new(),
snapshot_id: 1,
total_buckets: 1,
files: vec![base_file, partial_file],
},
FileRowRange {
first_row_id: 100,
last_row_id: 149,
row_count: 50,
partition: vec![],
bucket: 0,
bucket_path: String::new(),
snapshot_id: 1,
total_buckets: 1,
files: vec![make_test_file_meta(
"base-1.parquet",
50,
Some(100),
1,
None,
)],
},
];
// row_id 0 -> first group (2 files)
let (pos, range) = find_owning_file(&file_index, 0).unwrap();
assert_eq!(pos, 0);
assert_eq!(range.files.len(), 2);
// row_id 50 -> still first group
let (pos, range) = find_owning_file(&file_index, 50).unwrap();
assert_eq!(pos, 0);
assert_eq!(range.row_count, 100);
// row_id 99 -> last row of first group
let (pos, _) = find_owning_file(&file_index, 99).unwrap();
assert_eq!(pos, 0);
// row_id 100 -> second group (1 file)
let (pos, range) = find_owning_file(&file_index, 100).unwrap();
assert_eq!(pos, 1);
assert_eq!(range.files.len(), 1);
// row_id 200 -> not found
assert!(find_owning_file(&file_index, 200).is_none());
}
#[test]
fn test_file_group_construction_from_overlapping_files() {
// Verify that group_by_overlapping_row_id correctly groups base + partial files,
// and that we can build FileRowRange from the result.
let base = make_test_file_meta("base.parquet", 100, Some(0), 1, None);
let partial1 = make_test_file_meta(
"partial1.parquet",
100,
Some(0),
2,
Some(vec!["name".to_string()]),
);
let partial2 = make_test_file_meta(
"partial2.parquet",
100,
Some(0),
3,
Some(vec!["value".to_string()]),
);
let separate = make_test_file_meta("separate.parquet", 50, Some(200), 1, None);
let groups = group_by_overlapping_row_id(vec![base, partial1, partial2, separate]);
// Should produce 2 groups: [base, partial1, partial2] and [separate]
assert_eq!(groups.len(), 2);
assert_eq!(groups[0].len(), 3);
assert_eq!(groups[1].len(), 1);
// Build FileRowRange from first group
let group = &groups[0];
let first_row_id = group.iter().filter_map(|f| f.first_row_id).min().unwrap();
let last_row_id = group
.iter()
.filter_map(|f| f.row_id_range().map(|(_, end)| end))
.max()
.unwrap();
let row_count = group.iter().map(|f| f.row_count).max().unwrap();
assert_eq!(first_row_id, 0);
assert_eq!(last_row_id, 99);
assert_eq!(row_count, 100);
}
#[tokio::test]
async fn test_rejects_non_data_evolution_table() {
let file_io = test_file_io();
let schema = Schema::builder()
.column("id", DataType::Int(IntType::new()))
.build()
.unwrap();
let table_schema = TableSchema::new(0, &schema);
let table = Table::new(
file_io,
Identifier::new("default", "test"),
"memory:/test".to_string(),
table_schema,
None,
);
let result = DataEvolutionPartialWriter::new(&table, vec!["id".to_string()]);
assert!(result.is_err());
}
}