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 6b0d5c59 fix(arrow): read ragged CSV format-table rows permissively
(#975)
6b0d5c59 is described below
commit 6b0d5c599e7fa6f99a31f5917630db6e2b9ca2c0
Author: jackylee <[email protected]>
AuthorDate: Sun Oct 4 22:24:31 2026 +0800
fix(arrow): read ragged CSV format-table rows permissively (#975)
---
crates/paimon/src/arrow/format/text.rs | 56 +++++++--
crates/paimon/src/spec/schema.rs | 209 +++++++++++++++++++++++++++++++++
2 files changed, 255 insertions(+), 10 deletions(-)
diff --git a/crates/paimon/src/arrow/format/text.rs
b/crates/paimon/src/arrow/format/text.rs
index d46c5411..61f1ce0f 100644
--- a/crates/paimon/src/arrow/format/text.rs
+++ b/crates/paimon/src/arrow/format/text.rs
@@ -842,20 +842,23 @@ fn decode_text_chunk(
message: format!("Invalid UTF-8 in CSV file: {e}"),
source: Some(Box::new(e)),
})?;
- let row = if line.trim().is_empty() {
+ let mut row = if line.trim().is_empty() {
vec![None; schema.fields().len()]
} else {
parse_csv_line(line, options)?
};
- if row.len() != schema.fields().len() {
- return Err(Error::DataInvalid {
- message: format!(
- "CSV row has {} fields, expected {}",
- row.len(),
- schema.fields().len()
- ),
- source: None,
- });
+ // Read permissively, matching Java `CsvParser` under the
default
+ // `csv.mode=permissive`: a row with fewer fields than the
schema
+ // pads the missing trailing columns with null, and a row
with more
+ // fields drops the extras. Failing the whole scan on a
field-count
+ // mismatch rejected files Java reads fine — e.g. reading
a table
+ // after ADD COLUMN (old files carry one fewer column), or
any
+ // externally produced CSV that omits an optional trailing
column.
+ let field_count = schema.fields().len();
+ if row.len() < field_count {
+ row.resize(field_count, None);
+ } else if row.len() > field_count {
+ row.truncate(field_count);
}
Ok(row)
})
@@ -1483,4 +1486,37 @@ mod tests {
]
);
}
+
+ #[test]
+ fn csv_reads_ragged_rows_permissively() {
+ // A 3-column CSV format-table with a short row (missing the trailing
+ // column, e.g. an old file read after ADD COLUMN) and a long row (an
+ // extra trailing field). Java reads both under the default permissive
+ // mode; the reader must pad/truncate rather than fail the whole scan.
+ let schema = Arc::new(Schema::new(vec![
+ Field::new("a", DataType::Utf8, true),
+ Field::new("b", DataType::Utf8, true),
+ Field::new("c", DataType::Utf8, true),
+ ]));
+ let options = TextOptions::new(TextKind::Csv,
&std::collections::HashMap::new()).unwrap();
+ let lines: Vec<Vec<u8>> = vec![b"x,y".to_vec(), b"p,q,r".to_vec(),
b"1,2,3,4".to_vec()];
+ let batches = decode_text_chunk(&lines, TextKind::Csv, &options,
&schema).unwrap();
+ let total: usize = batches.iter().map(|b| b.num_rows()).sum();
+ assert_eq!(total, 3);
+ let c = batches[0]
+ .column(2)
+ .as_any()
+ .downcast_ref::<StringArray>()
+ .expect("utf8 column");
+ assert!(
+ c.is_null(0),
+ "short row must pad the trailing column with null"
+ );
+ assert_eq!(c.value(1), "r");
+ assert_eq!(
+ c.value(2),
+ "3",
+ "long row must drop the extra trailing field"
+ );
+ }
}
diff --git a/crates/paimon/src/spec/schema.rs b/crates/paimon/src/spec/schema.rs
index 095096b2..d3d1913a 100644
--- a/crates/paimon/src/spec/schema.rs
+++ b/crates/paimon/src/spec/schema.rs
@@ -360,6 +360,34 @@ impl TableSchema {
let field =
DataField::new(id, name.to_string(),
data_type).with_description(comment);
insert_field_with_move(&mut fields, field,
column_move.as_ref(), full_name)?;
+ // A CSV format table reads existing files positionally (no
+ // per-column header mapping), so a new column placed
before an
+ // existing physical column shifts every later one: the
+ // permissive reader would pad/truncate against the wrong
+ // positions and silently mis-assign old rows. Partition
keys
+ // are excluded from the physical CSV layout (as Java's
+ // FormatReadBuilder does), so only non-partition fields
after
+ // the new column count. Appending after the last physical
+ // column is safe. Reject the shifting case, symmetric
with the
+ // DropColumn guard.
+ {
+ let core_options =
CoreOptions::new(&new_schema.options);
+ let pos = field_index(&fields,
name).unwrap_or(fields.len());
+ let shifts_physical = fields
+ .iter()
+ .skip(pos + 1)
+ .any(|f| !new_schema.partition_keys.iter().any(|k|
k == f.name()));
+ if core_options.is_format_table()
+ && core_options.file_format() == "csv"
+ && shifts_physical
+ {
+ return Err(crate::Error::Unsupported {
+ message: format!(
+ "Cannot add non-trailing column '{name}'
to a CSV format table: it shifts the physical column positions of existing
files"
+ ),
+ });
+ }
+ }
}
SchemaChange::RenameColumn {
field_names,
@@ -457,6 +485,32 @@ impl TableSchema {
});
}
}
+ // A CSV format table reads existing files positionally (no
+ // per-column header mapping), so dropping a column before
an
+ // existing physical column shifts every later one: the
+ // permissive reader would truncate the trailing field and
+ // mis-assign the rest, silently returning wrong values.
+ // Partition keys are excluded from the physical CSV
layout (as
+ // Java's FormatReadBuilder does), so only non-partition
fields
+ // after the dropped column count. Dropping the last
physical
+ // column is safe. Reject the shifting case.
+ {
+ let core_options =
CoreOptions::new(&new_schema.options);
+ let shifts_physical = fields
+ .iter()
+ .skip(idx + 1)
+ .any(|f| !new_schema.partition_keys.iter().any(|k|
k == f.name()));
+ if core_options.is_format_table()
+ && core_options.file_format() == "csv"
+ && shifts_physical
+ {
+ return Err(crate::Error::Unsupported {
+ message: format!(
+ "Cannot drop non-trailing column '{name}'
of a CSV format table: it shifts the physical column positions of existing
files"
+ ),
+ });
+ }
+ }
if fields.len() == 1 {
return Err(crate::Error::Unsupported {
message: "Cannot drop all fields in
table".to_string(),
@@ -5608,6 +5662,161 @@ mod tests {
);
}
+ #[test]
+ fn test_csv_format_table_rejects_dropping_non_trailing_column() {
+ // A CSV format table reads files positionally; dropping a non-trailing
+ // column shifts physical positions and would make the permissive
reader
+ // silently mis-decode old rows. The trailing column is safe to drop.
+ let build = || {
+ TableSchema::new(
+ 0,
+ &Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column("label",
DataType::VarChar(VarCharType::string_type()))
+ .column("extra",
DataType::VarChar(VarCharType::string_type()))
+ .option("type", "format-table")
+ .option("file.format", "csv")
+ .build()
+ .unwrap(),
+ )
+ };
+
+ let err = build()
+ .apply_changes(vec![crate::spec::SchemaChange::drop_column(
+ "label".to_string(),
+ )])
+ .unwrap_err();
+ assert!(
+ matches!(err, crate::Error::Unsupported { ref message }
+ if message.contains("physical column positions") &&
message.contains("label")),
+ "dropping a non-trailing CSV column should be rejected, got
{err:?}"
+ );
+
+ // Dropping the trailing column keeps positions aligned and is allowed.
+ build()
+ .apply_changes(vec![crate::spec::SchemaChange::drop_column(
+ "extra".to_string(),
+ )])
+ .unwrap();
+ }
+
+ #[test]
+ fn test_csv_format_table_rejects_adding_non_trailing_column() {
+ // Symmetric with the drop guard: a CSV format table reads files
+ // positionally, so a new column that lands anywhere but last shifts
the
+ // physical positions of existing files. The permissive reader would
then
+ // mis-assign old rows (e.g. `(1,"old")` read as `(id, extra, label) =
+ // (1,"old",NULL)`). Appending the column at the end is safe.
+ let build = || {
+ TableSchema::new(
+ 0,
+ &Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column("label",
DataType::VarChar(VarCharType::string_type()))
+ .option("type", "format-table")
+ .option("file.format", "csv")
+ .build()
+ .unwrap(),
+ )
+ };
+ let varchar = || DataType::VarChar(VarCharType::string_type());
+
+ // Inserting after a non-last column, or first, shifts existing
positions.
+ for column_move in [
+ crate::spec::ColumnMove::move_after("extra".to_string(),
"id".to_string()),
+ crate::spec::ColumnMove::move_first("extra".to_string()),
+ ] {
+ let err = build()
+ .apply_changes(vec![
+
crate::spec::SchemaChange::add_column_with_description_and_column_move(
+ "extra".to_string(),
+ varchar(),
+ String::new(),
+ column_move,
+ ),
+ ])
+ .unwrap_err();
+ assert!(
+ matches!(err, crate::Error::Unsupported { ref message }
+ if message.contains("physical column positions") &&
message.contains("extra")),
+ "adding a non-trailing CSV column should be rejected, got
{err:?}"
+ );
+ }
+
+ // Appending the new column (plain add, or an explicit move to last)
keeps
+ // existing positions, so old files just pad it with null.
+ build()
+ .apply_changes(vec![crate::spec::SchemaChange::add_column(
+ "extra".to_string(),
+ varchar(),
+ )])
+ .unwrap();
+ build()
+ .apply_changes(vec![
+
crate::spec::SchemaChange::add_column_with_description_and_column_move(
+ "extra".to_string(),
+ varchar(),
+ String::new(),
+ crate::spec::ColumnMove::move_last("extra".to_string()),
+ ),
+ ])
+ .unwrap();
+ }
+
+ #[test]
+ fn test_csv_format_table_partition_columns_excluded_from_physical_shift() {
+ // Partition columns are not part of the physical CSV, so the physical
+ // layout of `(id, label, pt)` partitioned by `pt` is `(id, label)`.
+ // Dropping `label` (last physical) and appending `extra` after it are
+ // therefore safe trailing changes and must be accepted.
+ let build = || {
+ TableSchema::new(
+ 0,
+ &Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column("label",
DataType::VarChar(VarCharType::string_type()))
+ .column("pt",
DataType::VarChar(VarCharType::string_type()))
+ .partition_keys(vec!["pt".to_string()])
+ .option("type", "format-table")
+ .option("file.format", "csv")
+ .build()
+ .unwrap(),
+ )
+ };
+ let varchar = || DataType::VarChar(VarCharType::string_type());
+
+ // Dropping the last physical column (pt is partition-excluded) is
safe.
+ build()
+ .apply_changes(vec![crate::spec::SchemaChange::drop_column(
+ "label".to_string(),
+ )])
+ .unwrap();
+
+ // Adding after the last physical column (before only `pt`) is safe.
+ build()
+ .apply_changes(vec![
+
crate::spec::SchemaChange::add_column_with_description_and_column_move(
+ "extra".to_string(),
+ varchar(),
+ String::new(),
+ crate::spec::ColumnMove::move_after("extra".to_string(),
"label".to_string()),
+ ),
+ ])
+ .unwrap();
+
+ // A genuine physical shift (dropping `id`, before `label`) is still
rejected.
+ let err = build()
+ .apply_changes(vec![crate::spec::SchemaChange::drop_column(
+ "id".to_string(),
+ )])
+ .unwrap_err();
+ assert!(
+ matches!(err, crate::Error::Unsupported { ref message }
+ if message.contains("physical column positions")),
+ "{err:?}"
+ );
+ }
+
#[test]
fn test_bucket_key_history_rejects_destructive_column_changes() {
let table_schema = TableSchema::new(