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 fb82a4cc test(spec): cover untested partial-update and blob-view error
paths (#688)
fb82a4cc is described below
commit fb82a4ccac0481bdbfd75bf4bdec0709bc2eda6f
Author: jackylee <[email protected]>
AuthorDate: Sun Aug 9 21:06:29 2026 +0800
test(spec): cover untested partial-update and blob-view error paths (#688)
---
crates/paimon/src/spec/blob_view_struct.rs | 123 +++++++++++++++++++++++++++++
crates/paimon/src/spec/partial_update.rs | 85 ++++++++++++++++++++
2 files changed, 208 insertions(+)
diff --git a/crates/paimon/src/spec/blob_view_struct.rs
b/crates/paimon/src/spec/blob_view_struct.rs
index c76ff69b..ce37848c 100644
--- a/crates/paimon/src/spec/blob_view_struct.rs
+++ b/crates/paimon/src/spec/blob_view_struct.rs
@@ -214,4 +214,127 @@ mod tests {
matches!(err, Error::DataInvalid { message, .. } if
message.contains("trailing bytes"))
);
}
+
+ /// Assemble a payload from parts so that individual header fields can be
+ /// corrupted independently of what [`BlobViewStruct::serialize`] would
emit.
+ fn payload(
+ version: u8,
+ magic: u64,
+ identifier_length: i32,
+ identifier_bytes: &[u8],
+ field_id: i32,
+ row_id: i64,
+ ) -> Vec<u8> {
+ let mut buf = Vec::new();
+ buf.push(version);
+ buf.extend_from_slice(&magic.to_le_bytes());
+ buf.extend_from_slice(&identifier_length.to_le_bytes());
+ buf.extend_from_slice(identifier_bytes);
+ buf.extend_from_slice(&field_id.to_le_bytes());
+ buf.extend_from_slice(&row_id.to_le_bytes());
+ buf
+ }
+
+ #[test]
+ fn test_rejects_empty_payload() {
+ let err = BlobViewStruct::deserialize(&[]).unwrap_err();
+ assert!(
+ matches!(err, Error::DataInvalid { ref message, .. } if
message.contains("too short")),
+ "expected too-short error, got {err:?}"
+ );
+ }
+
+ #[test]
+ fn test_rejects_unsupported_version() {
+ let bytes = payload(CURRENT_VERSION + 1, MAGIC, 9, b"db.source", 3,
42);
+
+ let err = BlobViewStruct::deserialize(&bytes).unwrap_err();
+ assert!(
+ matches!(err, Error::Unsupported { ref message }
+ if message.contains("Expecting BlobViewStruct version")),
+ "expected version error, got {err:?}"
+ );
+ }
+
+ #[test]
+ fn test_rejects_bad_magic() {
+ let bytes = payload(CURRENT_VERSION, !MAGIC, 9, b"db.source", 3, 42);
+
+ let err = BlobViewStruct::deserialize(&bytes).unwrap_err();
+ assert!(
+ matches!(err, Error::DataInvalid { ref message, .. }
+ if message.contains("missing magic header")),
+ "expected magic error, got {err:?}"
+ );
+ }
+
+ #[test]
+ fn test_rejects_negative_identifier_length() {
+ let bytes = payload(CURRENT_VERSION, MAGIC, -1, b"db.source", 3, 42);
+
+ let err = BlobViewStruct::deserialize(&bytes).unwrap_err();
+ assert!(
+ matches!(err, Error::DataInvalid { ref message, .. }
+ if message.contains("negative identifier length")),
+ "expected negative-length error, got {err:?}"
+ );
+ }
+
+ #[test]
+ fn test_rejects_identifier_length_exceeding_data_size() {
+ let bytes = payload(CURRENT_VERSION, MAGIC, 64, b"db.source", 3, 42);
+
+ let err = BlobViewStruct::deserialize(&bytes).unwrap_err();
+ assert!(
+ matches!(err, Error::DataInvalid { ref message, .. }
+ if message.contains("identifier length exceeds data size")),
+ "expected length-overflow error, got {err:?}"
+ );
+ }
+
+ #[test]
+ fn test_rejects_non_utf8_identifier() {
+ let invalid = [b'd', b'b', b'.', 0xFF];
+ let bytes = payload(CURRENT_VERSION, MAGIC, 4, &invalid, 3, 42);
+
+ let err = BlobViewStruct::deserialize(&bytes).unwrap_err();
+ assert!(
+ matches!(err, Error::DataInvalid { ref message, .. }
+ if message.contains("Invalid UTF-8 in BlobViewStruct
identifier")),
+ "expected UTF-8 error, got {err:?}"
+ );
+ }
+
+ #[test]
+ fn test_rejects_identifier_without_database() {
+ let bytes = payload(CURRENT_VERSION, MAGIC, 6, b"source", 3, 42);
+
+ let err = BlobViewStruct::deserialize(&bytes).unwrap_err();
+ assert!(
+ matches!(err, Error::DataInvalid { ref message, .. }
+ if message.contains("must be 'database.table'")),
+ "expected identifier-shape error, got {err:?}"
+ );
+ }
+
+ #[test]
+ fn test_is_blob_view_struct_rejects_short_and_mismatched_payloads() {
+ assert!(!BlobViewStruct::is_blob_view_struct(&[0u8; 8]));
+ assert!(!BlobViewStruct::is_blob_view_struct(&payload(
+ CURRENT_VERSION + 1,
+ MAGIC,
+ 9,
+ b"db.source",
+ 3,
+ 42
+ )));
+ assert!(!BlobViewStruct::is_blob_view_struct(&payload(
+ CURRENT_VERSION,
+ !MAGIC,
+ 9,
+ b"db.source",
+ 3,
+ 42
+ )));
+ }
}
diff --git a/crates/paimon/src/spec/partial_update.rs
b/crates/paimon/src/spec/partial_update.rs
index b8a0018e..d7fd9d73 100644
--- a/crates/paimon/src/spec/partial_update.rs
+++ b/crates/paimon/src/spec/partial_update.rs
@@ -933,4 +933,89 @@ mod tests {
])
);
}
+
+ #[test]
+ fn test_validate_sequence_groups_rejects_unknown_field() {
+ let options =
partial_update_options(&[("fields.version.sequence-group", "prcie")]);
+ let config = PartialUpdateConfig::new(&options);
+ let fields = vec![
+ DataField::new(0, "id".to_string(), DataType::Int(IntType::new())),
+ DataField::new(1, "version".to_string(),
DataType::Int(IntType::new())),
+ DataField::new(2, "price".to_string(),
DataType::Int(IntType::new())),
+ ];
+
+ let err = config
+ .validated_sequence_groups(&fields, &["id".to_string()])
+ .unwrap_err();
+
+ assert!(
+ matches!(err, crate::Error::ConfigInvalid { ref message }
+ if message.contains("prcie")
+ && message.contains("does not exist in the table schema")),
+ "expected unknown sequence-group field error, got {err:?}"
+ );
+ }
+
+ #[test]
+ fn test_validate_aggregate_functions_rejects_empty_listagg_field() {
+ let options = partial_update_options(&[("fields..list-agg-delimiter",
";")]);
+ let config = PartialUpdateConfig::new(&options);
+ let fields = vec![
+ DataField::new(0, "id".to_string(), DataType::Int(IntType::new())),
+ DataField::new(1, "note".to_string(),
DataType::Int(IntType::new())),
+ ];
+
+ let err = config
+ .validated_aggregate_functions(&fields, &["id".to_string()])
+ .unwrap_err();
+
+ assert!(
+ matches!(err, crate::Error::ConfigInvalid { ref message }
+ if message.contains("Invalid partial-update listagg option")
+ && message.contains("fields..list-agg-delimiter")),
+ "expected invalid listagg option error, got {err:?}"
+ );
+ }
+
+ #[test]
+ fn
test_validate_aggregate_functions_rejects_empty_aggregate_function_field() {
+ let options = partial_update_options(&[("fields..aggregate-function",
"sum")]);
+ let config = PartialUpdateConfig::new(&options);
+ let fields = vec![
+ DataField::new(0, "id".to_string(), DataType::Int(IntType::new())),
+ DataField::new(1, "price".to_string(),
DataType::Int(IntType::new())),
+ ];
+
+ let err = config
+ .validated_aggregate_functions(&fields, &["id".to_string()])
+ .unwrap_err();
+
+ assert!(
+ matches!(err, crate::Error::ConfigInvalid { ref message }
+ if message.contains("Invalid partial-update aggregate-function
option")
+ && message.contains("fields..aggregate-function")),
+ "expected invalid aggregate-function option error, got {err:?}"
+ );
+ }
+
+ #[test]
+ fn test_validate_aggregate_functions_rejects_unknown_default_function() {
+ let options =
partial_update_options(&[(FIELDS_DEFAULT_AGG_FUNCTION_OPTION, "sume")]);
+ let config = PartialUpdateConfig::new(&options);
+ let fields = vec![
+ DataField::new(0, "id".to_string(), DataType::Int(IntType::new())),
+ DataField::new(1, "price".to_string(),
DataType::Int(IntType::new())),
+ ];
+
+ let err = config
+ .validated_aggregate_functions(&fields, &["id".to_string()])
+ .unwrap_err();
+
+ assert!(
+ matches!(err, crate::Error::ConfigInvalid { ref message }
+ if message.contains("sume")
+ && message.contains(FIELDS_DEFAULT_AGG_FUNCTION_OPTION)),
+ "expected unknown default aggregate function error, got {err:?}"
+ );
+ }
}