This is an automated email from the ASF dual-hosted git repository. github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/main/pr-3334-050ddfda869068cf2d19683e45d447314181415b in repository https://gitbox.apache.org/repos/asf/iceberg-rust.git
commit 2fddb1601a9af1c97a559baa9865e1494d93756c Author: Minh Vu <[email protected]> AuthorDate: Sun Oct 11 18:19:26 2026 +0000 fix(spec): reject boolean literals for non-boolean types (#3334) * fix(spec): reject boolean literals for non-boolean types * test(spec): polish boolean literal validation coverage --- crates/iceberg/src/spec/values/serde.rs | 5 +- crates/iceberg/src/spec/values/tests.rs | 150 +++++++++++++++++++++++++++++++- 2 files changed, 153 insertions(+), 2 deletions(-) diff --git a/crates/iceberg/src/spec/values/serde.rs b/crates/iceberg/src/spec/values/serde.rs index a9c611517..7fa41159d 100644 --- a/crates/iceberg/src/spec/values/serde.rs +++ b/crates/iceberg/src/spec/values/serde.rs @@ -428,7 +428,10 @@ pub(crate) mod _serde { }; match self { RawLiteralEnum::Null => Ok(None), - RawLiteralEnum::Boolean(v) => Ok(Some(Literal::bool(v))), + RawLiteralEnum::Boolean(v) => match ty { + Type::Primitive(PrimitiveType::Boolean) => Ok(Some(Literal::bool(v))), + _ => Err(invalid_err("boolean")), + }, RawLiteralEnum::Int(v) => match ty { Type::Primitive(PrimitiveType::Int) => Ok(Some(Literal::int(v))), Type::Primitive(PrimitiveType::Long) => Ok(Some(Literal::long(i64::from(v)))), diff --git a/crates/iceberg/src/spec/values/tests.rs b/crates/iceberg/src/spec/values/tests.rs index 61f83efee..63ff9f636 100644 --- a/crates/iceberg/src/spec/values/tests.rs +++ b/crates/iceberg/src/spec/values/tests.rs @@ -31,7 +31,9 @@ use crate::ErrorKind; use crate::avro::schema_to_avro_schema; use crate::spec::Schema; use crate::spec::Type::Primitive; -use crate::spec::datatypes::{ListType, MapType, NestedField, PrimitiveType, StructType, Type}; +use crate::spec::datatypes::{ + ListType, MapType, NestedField, PrimitiveType, StructType, Type, VariantType, +}; use crate::spec::values::datum::{INT_MAX, INT_MIN, LONG_MAX, LONG_MIN}; use crate::spec::values::{Datum, Literal, Map, PrimitiveLiteral, RawLiteral, Struct}; @@ -820,6 +822,152 @@ fn test_raw_literal_bytes_unsupported_type() { check_raw_literal_bytes_error_via_avro(bytes, &Primitive(PrimitiveType::Int)); } +#[test] +fn test_raw_literal_boolean_round_trip() { + for value in [false, true] { + let ty = Primitive(PrimitiveType::Boolean); + check_raw_literal_json_serde(Literal::bool(value), &ty); + check_convert_with_avro(Literal::bool(value), &ty); + + for json in [ + serde_json::json!({"type": "boolean", "literal": value}), + serde_json::json!(["boolean", value]), + ] { + assert_eq!( + serde_json::from_value::<Datum>(json).unwrap(), + Datum::bool(value) + ); + } + } +} + +#[test] +fn test_datum_json_boolean_rejects_type_mismatch() { + for ty in ["int", "long", "string", "unknown"] { + for value in [false, true] { + for json in [ + serde_json::json!({"type": ty, "literal": value}), + serde_json::json!([ty, value]), + ] { + let error = serde_json::from_value::<Datum>(json).unwrap_err(); + assert!(error.to_string().contains("raw literal (boolean)")); + assert!(error.to_string().contains("type mismatch")); + } + } + } +} + +#[test] +fn test_raw_literal_null_for_boolean_type() { + let ty = Primitive(PrimitiveType::Boolean); + let avro_literal: RawLiteral = apache_avro::from_value(&Value::Null).unwrap(); + let json_literal: RawLiteral = serde_json::from_str("null").unwrap(); + + for literal in [avro_literal, json_literal] { + assert_eq!(literal.try_into(&ty).unwrap(), None); + } +} + +#[test] +fn test_raw_literal_boolean_rejects_type_mismatch() { + let types = [ + Primitive(PrimitiveType::Int), + Primitive(PrimitiveType::Long), + Primitive(PrimitiveType::Float), + Primitive(PrimitiveType::Double), + Primitive(PrimitiveType::Decimal { + precision: 9, + scale: 2, + }), + Primitive(PrimitiveType::Date), + Primitive(PrimitiveType::Time), + Primitive(PrimitiveType::Timestamp), + Primitive(PrimitiveType::Timestamptz), + Primitive(PrimitiveType::TimestampNs), + Primitive(PrimitiveType::TimestamptzNs), + Primitive(PrimitiveType::String), + Primitive(PrimitiveType::Uuid), + Primitive(PrimitiveType::Fixed(1)), + Primitive(PrimitiveType::Binary), + Primitive(PrimitiveType::Unknown), + Type::Struct(StructType::new(vec![])), + Type::List(ListType::new( + NestedField::list_element(1, Primitive(PrimitiveType::Boolean), false).into(), + )), + Type::Map(MapType::new( + NestedField::map_key_element(1, Primitive(PrimitiveType::String)).into(), + NestedField::map_value_element(2, Primitive(PrimitiveType::Boolean), false).into(), + )), + Type::Variant(VariantType), + ]; + + for value in [false, true] { + for ty in &types { + let avro_literal: RawLiteral = apache_avro::from_value(&Value::Boolean(value)).unwrap(); + let json_literal: RawLiteral = + serde_json::from_value(serde_json::json!(value)).unwrap(); + + for literal in [avro_literal, json_literal] { + let error = literal.try_into(ty).unwrap_err(); + assert_eq!(error.kind(), ErrorKind::DataInvalid); + assert!(error.to_string().contains("raw literal (boolean)")); + assert!(error.to_string().contains("type mismatch")); + } + } + } +} + +#[test] +fn test_raw_literal_boolean_rejects_nested_type_mismatch() { + let cases = [ + ( + serde_json::json!({"col": true}), + Value::Record(vec![("col".to_string(), Value::Boolean(true))]), + Type::Struct(StructType::new(vec![ + NestedField::required(1, "col", Primitive(PrimitiveType::Int)).into(), + ])), + ), + ( + serde_json::json!([false]), + Value::Array(vec![Value::Boolean(false)]), + Type::List(ListType::new( + NestedField::list_element(1, Primitive(PrimitiveType::Int), false).into(), + )), + ), + ( + serde_json::json!({"key": true}), + Value::Map([("key".to_string(), Value::Boolean(true))].into()), + Type::Map(MapType::new( + NestedField::map_key_element(1, Primitive(PrimitiveType::String)).into(), + NestedField::map_value_element(2, Primitive(PrimitiveType::Int), false).into(), + )), + ), + ( + serde_json::json!([{"key": false, "value": 1}]), + Value::Array(vec![Value::Record(vec![ + ("key".to_string(), Value::Boolean(false)), + ("value".to_string(), Value::Int(1)), + ])]), + Type::Map(MapType::new( + NestedField::map_key_element(1, Primitive(PrimitiveType::Int)).into(), + NestedField::map_value_element(2, Primitive(PrimitiveType::Int), false).into(), + )), + ), + ]; + + for (json_value, avro_value, ty) in cases { + let avro_literal: RawLiteral = apache_avro::from_value(&avro_value).unwrap(); + let json_literal: RawLiteral = serde_json::from_value(json_value).unwrap(); + + for literal in [avro_literal, json_literal] { + let error = literal.try_into(&ty).unwrap_err(); + assert_eq!(error.kind(), ErrorKind::DataInvalid); + assert!(error.to_string().contains("raw literal (boolean)")); + assert!(error.to_string().contains("type mismatch")); + } + } +} + #[test] fn avro_convert_test_int() { check_convert_with_avro(
