diff --git a/avro/src/documentation/serde_data_model_to_avro.rs b/avro/src/documentation/serde_data_model_to_avro.rs index 7016dbf6..fd8fafbc 100644 --- a/avro/src/documentation/serde_data_model_to_avro.rs +++ b/avro/src/documentation/serde_data_model_to_avro.rs @@ -40,6 +40,8 @@ //! - **string** => [`Schema::String`] //! - **byte array** => [`Schema::Bytes`] or [`Schema::Fixed`] //! - **option** => [`Schema::Union([Schema::Null, _])`](crate::schema::Schema::Union) +//! - If the schema of `T` is also a union schema and does not have a null variant, the schemas +//! are allowed to be merged. This results in a "flattened" `Option`. //! - **unit** => [`Schema::Null`] //! - **unit struct** => [`Schema::Record`] with the unqualified name equal to the name of the struct and zero fields //! - **unit variant** => See [Enums](#enums) diff --git a/avro/src/serde/derive.rs b/avro/src/serde/derive.rs index 6d66fcec..9dbf5711 100644 --- a/avro/src/serde/derive.rs +++ b/avro/src/serde/derive.rs @@ -583,15 +583,27 @@ impl AvroSchemaComponent for Option where T: AvroSchemaComponent, { + /// The schema is a [`Schema::Union`] with the first variant set to [`Schema::Null`]. + /// + /// If the schema of `T` is a union, the variants of this union will be appended. If the `T` union + /// already contains a `Schema::Null` the construction will panic. + /// If the schema of `T` is not a union, it will be appended to the union. + /// + /// # Panics + /// If `T::get_schema_in_ctxt` returns a [`Schema::Union`] where one variant is [`Schema::Null`]. fn get_schema_in_ctxt( named_schemas: &mut HashSet, enclosing_namespace: NamespaceRef, ) -> Schema { - let variants = vec![ - Schema::Null, - T::get_schema_in_ctxt(named_schemas, enclosing_namespace), - ]; - + let variants = match T::get_schema_in_ctxt(named_schemas, enclosing_namespace) { + Schema::Union(mut union) => { + // It would be more efficient to append the null schema, but the (de)serializers have + // a fast path if the first variant is the null schema + union.schemas.insert(0, Schema::Null); + union.schemas + } + schema => vec![Schema::Null, schema], + }; Schema::Union( UnionSchema::new(variants).expect("Option must produce a valid (non-nested) union"), ) @@ -950,11 +962,12 @@ mod tests { use apache_avro_test_helper::TestResult; use crate::{ - AvroSchema, Schema, + AvroSchema, AvroSchemaComponent, Schema, reader::datum::GenericDatumReader, - schema::{FixedSchema, Name}, + schema::{FixedSchema, Name, NamespaceRef}, writer::datum::GenericDatumWriter, }; + use std::collections::HashSet; #[test] fn avro_rs_401_str() -> TestResult { @@ -1070,9 +1083,7 @@ mod tests { } #[test] - #[should_panic( - expected = "Option must produce a valid (non-nested) union: Error { details: Unions may not directly contain a union }" - )] + #[should_panic(expected = "Unions cannot contain duplicate types, found at least two Null")] fn avro_rs_489_option_option() { >>::get_schema(); } @@ -1207,4 +1218,25 @@ mod tests { ); Ok(()) } + + #[test] + fn test_nullable_complex_union() -> TestResult { + let schema = Schema::parse_str(r#"["null", "int", "string"]"#)?; + + #[allow(dead_code)] + enum MyUnion { + Int(i32), + String(String), + } + + impl AvroSchemaComponent for MyUnion { + fn get_schema_in_ctxt(_: &mut HashSet, _: NamespaceRef) -> Schema { + Schema::union(vec![Schema::Int, Schema::String]).expect("Union must be valid") + } + } + + assert_eq!(schema, Option::::get_schema()); + + Ok(()) + } } diff --git a/avro/src/serde/deser_schema/enums.rs b/avro/src/serde/deser_schema/enums.rs index bba48fdd..c8973f06 100644 --- a/avro/src/serde/deser_schema/enums.rs +++ b/avro/src/serde/deser_schema/enums.rs @@ -102,14 +102,31 @@ pub struct UnionEnumDeserializer<'s, 'r, R: Read, S: Borrow> { reader: &'r mut R, variants: &'s [Schema], config: Config<'s, S>, + /// The index of the null that belongs to the Option schema and has already been read. + flattened_option_null_index: Option, } impl<'s, 'r, R: Read, S: Borrow> UnionEnumDeserializer<'s, 'r, R, S> { - pub fn new(reader: &'r mut R, schema: &'s UnionSchema, config: Config<'s, S>) -> Self { + pub fn new( + reader: &'r mut R, + schema: &'s UnionSchema, + config: Config<'s, S>, + flattened_option_null_index: Option, + ) -> Self { Self { reader, variants: schema.variants(), config, + flattened_option_null_index, + } + } + + fn correct_index_for_serde(&self, serde_index: usize) -> usize { + match self.flattened_option_null_index { + // The index from Serde needs to be corrected for the flattened Option null schema, + // as Serde is not aware of the flattening. + Some(null_index) if serde_index >= null_index => serde_index - 1, + _ => serde_index, } } } @@ -124,15 +141,19 @@ impl<'de, 's, 'r, R: Read, S: Borrow> EnumAccess<'de> where V: DeserializeSeed<'de>, { - let index = zag_i32(self.reader)?; - let index = usize::try_from(index).map_err(|e| Details::ConvertI32ToUsize(e, index))?; + let index = if let Some(index) = self.flattened_option_null_index { + index + } else { + let index = zag_i32(self.reader)?; + usize::try_from(index).map_err(|e| Details::ConvertI32ToUsize(e, index))? + }; let schema = self.variants.get(index).ok_or(Details::GetUnionVariant { index: index as i64, num_variants: self.variants.len(), })?; - + let variant_index = self.correct_index_for_serde(index); Ok(( - seed.deserialize(IdentifierDeserializer::index(index as u32))?, + seed.deserialize(IdentifierDeserializer::index(variant_index as u32))?, UnionVariantAccess::new(schema, self.reader, self.config)?, )) } diff --git a/avro/src/serde/deser_schema/mod.rs b/avro/src/serde/deser_schema/mod.rs index 45287a39..60fd991b 100644 --- a/avro/src/serde/deser_schema/mod.rs +++ b/avro/src/serde/deser_schema/mod.rs @@ -35,11 +35,10 @@ mod tuple; use block::BlockDeserializer; use enums::PlainEnumDeserializer; +use enums::UnionEnumDeserializer; use record::RecordDeserializer; use tuple::{ManyTupleDeserializer, OneTupleDeserializer}; -use crate::serde::deser_schema::enums::UnionEnumDeserializer; - /// Configure the deserializer. #[derive(Debug)] pub struct Config<'s, S: Borrow> { @@ -79,6 +78,7 @@ pub struct SchemaAwareDeserializer<'s, 'r, R: Read, S: Borrow> { /// This schema is guaranteed to not be a [`Schema::Ref`]. schema: &'s Schema, config: Config<'s, S>, + flattened_option_null_index: Option, } impl<'s, 'r, R: Read, S: Borrow> SchemaAwareDeserializer<'s, 'r, R, S> { @@ -96,12 +96,14 @@ impl<'s, 'r, R: Read, S: Borrow> SchemaAwareDeserializer<'s, 'r, R, S> { reader, schema, config, + flattened_option_null_index: None, }) } else { Ok(Self { reader, schema, config, + flattened_option_null_index: None, }) } } @@ -129,12 +131,22 @@ impl<'s, 'r, R: Read, S: Borrow> SchemaAwareDeserializer<'s, 'r, R, S> { Ok(self) } + /// Create a new deserializer which is aware that the Option has already read the union index. + fn with_flattened_option_null_index(mut self, index: usize) -> Self { + self.flattened_option_null_index = Some(index); + self + } + /// Read the union and create a new deserializer with the existing reader and config. /// /// This will resolve the read schema if it is a reference. fn with_union(self, schema: &'s UnionSchema) -> Result { - let index = zag_i32(self.reader)?; - let index = usize::try_from(index).map_err(|e| Details::ConvertI32ToUsize(e, index))?; + let index = if let Some(index) = self.flattened_option_null_index { + index + } else { + let index = zag_i32(self.reader)?; + usize::try_from(index).map_err(|e| Details::ConvertI32ToUsize(e, index))? + }; let variant = schema.get_variant(index)?; self.with_different_schema(variant) } @@ -539,7 +551,6 @@ impl<'de, 's, 'r, R: Read, S: Borrow> Deserializer<'de> V: Visitor<'de>, { if let Schema::Union(union) = self.schema - && union.variants().len() == 2 && union.is_nullable() { let index = zag_i32(self.reader)?; @@ -548,7 +559,11 @@ impl<'de, 's, 'r, R: Read, S: Borrow> Deserializer<'de> if let Schema::Null = schema { visitor.visit_none() } else { - visitor.visit_some(self.with_different_schema(schema)?) + if union.variants().len() == 2 { + visitor.visit_some(self.with_different_schema(schema)?) + } else { + visitor.visit_some(self.with_flattened_option_null_index(index)) + } } } else { Err(self.error("option", "Expected Schema::Union([Schema::Null, _])")) @@ -723,9 +738,12 @@ impl<'de, 's, 'r, R: Read, S: Borrow> Deserializer<'de> Schema::Enum(schema) => { visitor.visit_enum(PlainEnumDeserializer::new(self.reader, schema)) } - Schema::Union(union) => { - visitor.visit_enum(UnionEnumDeserializer::new(self.reader, union, self.config)) - } + Schema::Union(union) => visitor.visit_enum(UnionEnumDeserializer::new( + self.reader, + union, + self.config, + self.flattened_option_null_index, + )), _ => Err(self.error("enum", "Expected Schema::Enum | Schema::Union")), } } diff --git a/avro/src/serde/ser_schema/mod.rs b/avro/src/serde/ser_schema/mod.rs index 5aba68b1..7f3d1b3a 100644 --- a/avro/src/serde/ser_schema/mod.rs +++ b/avro/src/serde/ser_schema/mod.rs @@ -87,6 +87,7 @@ pub struct SchemaAwareSerializer<'s, 'w, W: Write, S: Borrow> { /// This schema is guaranteed to not be a [`Schema::Ref`]. schema: &'s Schema, config: Config<'s, S>, + flattened_option_null_index: Option, } impl<'s, 'w, W: Write, S: Borrow> SchemaAwareSerializer<'s, 'w, W, S> { @@ -104,6 +105,7 @@ impl<'s, 'w, W: Write, S: Borrow> SchemaAwareSerializer<'s, 'w, W, S> { writer, schema, config, + flattened_option_null_index: None, }) } @@ -127,13 +129,28 @@ impl<'s, 'w, W: Write, S: Borrow> SchemaAwareSerializer<'s, 'w, W, S> { Ok(self) } + /// Create a new serializer that is aware that the index of the null variant has been consumed by the Option. + fn with_flattened_option_null_index(mut self, index: usize) -> Self { + self.flattened_option_null_index = Some(index); + self + } + + fn correct_index_from_serde(&self, variant_index: usize) -> usize { + match self.flattened_option_null_index { + // The index from Serde needs to be corrected for the flattened Option null schema, + // as Serde is not aware of the flattening. + Some(null_index) if variant_index >= null_index => variant_index + 1, + _ => variant_index, + } + } + /// Get the schema at the given index of the union, resolving references. fn get_resolved_union_variant( &self, union: &'s UnionSchema, - index: u32, + index: usize, ) -> Result<&'s Schema, Error> { - match union.get_variant(index as usize)? { + match union.get_variant(index)? { Schema::Ref { name } => self.config.get_schema(name), schema => Ok(schema), } @@ -374,7 +391,6 @@ impl<'s, 'w, W: Write, S: Borrow> Serializer for SchemaAwareSerializer<' fn serialize_none(self) -> Result { if let Schema::Union(union) = self.schema - && union.variants().len() == 2 && let Some(null_index) = union.index_of_schema_kind(SchemaKind::Null) { zig_i32(null_index as i32, &mut *self.writer) @@ -388,14 +404,17 @@ impl<'s, 'w, W: Write, S: Borrow> Serializer for SchemaAwareSerializer<' T: ?Sized + Serialize, { if let Schema::Union(union) = self.schema - && union.variants().len() == 2 && let Some(null_index) = union.index_of_schema_kind(SchemaKind::Null) { - let some_index = (null_index + 1) & 1; - let mut bytes_written = zig_i32(some_index as i32, &mut *self.writer)?; - bytes_written += - value.serialize(self.with_different_schema(&union.variants()[some_index])?)?; - Ok(bytes_written) + if union.variants().len() == 2 { + let some_index = (null_index + 1) & 1; + let mut bytes_written = zig_i32(some_index as i32, &mut *self.writer)?; + bytes_written += + value.serialize(self.with_different_schema(&union.variants()[some_index])?)?; + Ok(bytes_written) + } else { + value.serialize(self.with_flattened_option_null_index(null_index)) + } } else { Err(self.error("some", "Expected Schema::Union([Schema::Null, _])")) } @@ -428,7 +447,7 @@ impl<'s, 'w, W: Write, S: Borrow> Serializer for SchemaAwareSerializer<' fn serialize_unit_variant( self, - _name: &'static str, + name: &'static str, variant_index: u32, variant: &'static str, ) -> Result { @@ -445,14 +464,20 @@ impl<'s, 'w, W: Write, S: Borrow> Serializer for SchemaAwareSerializer<' Err(self.error("unit variant", format!(r#"Expected symbol "{variant}" at index {variant_index} in enum"#))) } } - Schema::Union(union) => match self.get_resolved_union_variant(union, variant_index)? { - // Bare union - Schema::Null => zig_i32(variant_index as i32, &mut *self.writer), - Schema::Record(record) if record.fields.is_empty() && record.name.name() == variant => { - // Union of records - zig_i32(variant_index as i32, &mut *self.writer) + Schema::Union(union) => { + let corrected_index = self.correct_index_from_serde(variant_index as usize); + match self.get_resolved_union_variant(union, corrected_index)? { + // Bare union + Schema::Null => zig_i32(corrected_index as i32, &mut *self.writer), + Schema::Record(record) if record.fields.is_empty() && record.name.name() == variant => { + // Union of records + zig_i32(corrected_index as i32, &mut *self.writer) + } + _ if let Some((_, Schema::Enum(_))) = union.find_named_schema(name, self.config.names)? => { + Err(self.error("unit variant", format!("Expected Schema::Null | Schema::Record(name: {variant}, fields: []) at index {corrected_index} in the union. If the type has a plain enum inside a untagged enum, this is not supported by Serde."))) + } + _ => Err(self.error("unit variant", format!("Expected Schema::Null | Schema::Record(name: {variant}, fields: []) at index {corrected_index} in the union"))), } - _ => Err(self.error("unit variant", format!("Expected Schema::Null | Schema::Record(name: {variant}, fields: []) at index {variant_index} in the union"))), } _ => Err(self.error("unit variant", format!("Expected Schema::Enum(symbols[{variant_index}] == {variant}) | Schema::Union(variants[{variant_index}] == Schema::Null | Schema::Record(name: {variant}, fields: []))"))), } @@ -490,28 +515,31 @@ impl<'s, 'w, W: Write, S: Borrow> Serializer for SchemaAwareSerializer<' where T: ?Sized + Serialize, { + let corrected_index = self.correct_index_from_serde(variant_index as usize); match self.schema { - Schema::Union(union) => match self.get_resolved_union_variant(union, variant_index)? { - Schema::Record(record) - if record.fields.len() == 1 - && record.name.name() == variant - && record - .attributes - .get("org.apache.avro.rust.union_of_records") - == Some(&Bool(true)) => - { - // Union of records - let mut bytes_written = zig_i32(variant_index as i32, &mut *self.writer)?; - let schema = &record.fields[0].schema; - bytes_written += value.serialize(self.with_different_schema(schema)?)?; - Ok(bytes_written) - } - schema => { - let mut bytes_written = zig_i32(variant_index as i32, &mut *self.writer)?; - bytes_written += value.serialize(self.with_different_schema(schema)?)?; - Ok(bytes_written) + Schema::Union(union) => { + match self.get_resolved_union_variant(union, corrected_index)? { + Schema::Record(record) + if record.fields.len() == 1 + && record.name.name() == variant + && record + .attributes + .get("org.apache.avro.rust.union_of_records") + == Some(&Bool(true)) => + { + // Union of records + let mut bytes_written = zig_i32(corrected_index as i32, &mut *self.writer)?; + let schema = &record.fields[0].schema; + bytes_written += value.serialize(self.with_different_schema(schema)?)?; + Ok(bytes_written) + } + schema => { + let mut bytes_written = zig_i32(corrected_index as i32, &mut *self.writer)?; + bytes_written += value.serialize(self.with_different_schema(schema)?)?; + Ok(bytes_written) + } } - }, + } _ => Err(self.error("newtype variant", "Expected Schema::Union")), } } @@ -583,12 +611,14 @@ impl<'s, 'w, W: Write, S: Borrow> Serializer for SchemaAwareSerializer<' variant: &'static str, len: usize, ) -> Result { + let corrected_index = self.correct_index_from_serde(variant_index as usize); if let Schema::Union(union) = self.schema - && let Schema::Record(record) = self.get_resolved_union_variant(union, variant_index)? + && let Schema::Record(record) = + self.get_resolved_union_variant(union, corrected_index)? && record.fields.len() == len && record.name.name() == variant { - let bytes_written = zig_i32(variant_index as i32, &mut *self.writer)?; + let bytes_written = zig_i32(corrected_index as i32, &mut *self.writer)?; Ok(ManyTupleSerializer::new( self.writer, record, @@ -596,7 +626,7 @@ impl<'s, 'w, W: Write, S: Borrow> Serializer for SchemaAwareSerializer<' Some(bytes_written), )) } else { - Err(self.error("tuple variant", format!("Expected Schema::Union(variants[{variant_index}] == Schema::Record(name: {variant}, fields.len() == {len}))"))) + Err(self.error("tuple variant", format!("Expected Schema::Union(variants[{corrected_index}] == Schema::Record(name: {variant}, fields.len() == {len}))"))) } } @@ -657,12 +687,14 @@ impl<'s, 'w, W: Write, S: Borrow> Serializer for SchemaAwareSerializer<' variant: &'static str, len: usize, ) -> Result { + let corrected_index = self.correct_index_from_serde(variant_index as usize); if let Schema::Union(union) = self.schema - && let Schema::Record(record) = self.get_resolved_union_variant(union, variant_index)? + && let Schema::Record(record) = + self.get_resolved_union_variant(union, corrected_index)? && record.fields.len() == len && record.name.name() == variant { - let bytes_written = zig_i32(variant_index as i32, &mut *self.writer)?; + let bytes_written = zig_i32(corrected_index as i32, &mut *self.writer)?; Ok(RecordSerializer::new( self.writer, record, @@ -670,7 +702,7 @@ impl<'s, 'w, W: Write, S: Borrow> Serializer for SchemaAwareSerializer<' Some(bytes_written), )) } else { - Err(self.error("struct variant", format!("Expected Schema::Union(variants[{variant_index}] == Schema::Record(name: {variant}, fields.len() == {len}))"))) + Err(self.error("struct variant", format!("Expected Schema::Union(variants[{corrected_index}] == Schema::Record(name: {variant}, fields.len() == {len}))"))) } } @@ -1919,21 +1951,15 @@ mod tests { } #[derive(Serialize)] - #[serde(untagged)] enum InnerUnion { IntField(i32), } let rs = ResolvedSchema::try_from(&schema)?; - // Flattening a Option into the underlying union is NOT supported let null_record = TestRecord { inner_union: None }; - assert_serialize_err( - null_record, - &schema, - rs.get_names(), - r#"Failed to serialize field 'innerUnion' of record RecordSchema { name: Name { name: "TestRecord", .. }, fields: [RecordField { name: "innerUnion", schema: Union(UnionSchema { schemas: [Null, Record(RecordSchema { name: Name { name: "innerRecordFoo", .. }, fields: [RecordField { name: "foo", schema: String, .. }], .. }), Record(RecordSchema { name: Name { name: "innerRecordBar", .. }, fields: [RecordField { name: "bar", schema: String, .. }], .. }), Int, String] }), .. }], .. }: Failed to serialize value of type `none`: Expected Schema::Union([Schema::Null, _])"#, - ); + assert_serialize(null_record, &schema, rs.get_names(), &[0]); + // Incorrect order, needs to be the 3rd variant, not the first one. let foo_record = TestRecord { inner_union: Some(InnerUnion::IntField(42)), }; @@ -1941,7 +1967,7 @@ mod tests { foo_record, &schema, rs.get_names(), - r#"Failed to serialize field 'innerUnion' of record RecordSchema { name: Name { name: "TestRecord", .. }, fields: [RecordField { name: "innerUnion", schema: Union(UnionSchema { schemas: [Null, Record(RecordSchema { name: Name { name: "innerRecordFoo", .. }, fields: [RecordField { name: "foo", schema: String, .. }], .. }), Record(RecordSchema { name: Name { name: "innerRecordBar", .. }, fields: [RecordField { name: "bar", schema: String, .. }], .. }), Int, String] }), .. }], .. }: Failed to serialize value of type `some`: Expected Schema::Union([Schema::Null, _])"#, + r#"Failed to serialize field 'innerUnion' of record RecordSchema { name: Name { name: "TestRecord", .. }, fields: [RecordField { name: "innerUnion", schema: Union(UnionSchema { schemas: [Null, Record(RecordSchema { name: Name { name: "innerRecordFoo", .. }, fields: [RecordField { name: "foo", schema: String, .. }], .. }), Record(RecordSchema { name: Name { name: "innerRecordBar", .. }, fields: [RecordField { name: "bar", schema: String, .. }], .. }), Int, String] }), .. }], .. }: Failed to serialize value of type `i32`: Expected Schema::Int | Schema::Date | Schema::TimeMillis"#, ); Ok(()) } diff --git a/avro/tests/avro_rs_528_nullable_union.rs b/avro/tests/avro_rs_528_nullable_union.rs new file mode 100644 index 00000000..86327acd --- /dev/null +++ b/avro/tests/avro_rs_528_nullable_union.rs @@ -0,0 +1,437 @@ +// 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. + +use std::fmt::Debug; + +use apache_avro::Schema; +use apache_avro::reader::datum::GenericDatumReader; +use apache_avro::writer::datum::GenericDatumWriter; +use pretty_assertions::assert_eq; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; + +#[track_caller] +fn assert_roundtrip(value: &T, schema: &Schema) +where + T: Serialize + DeserializeOwned + PartialEq + Debug, +{ + let serialized = GenericDatumWriter::builder(schema) + .build() + .unwrap() + .write_ser_to_vec(&value) + .unwrap(); + let deserialized: T = GenericDatumReader::builder(schema) + .build() + .unwrap() + .read_deser(&mut &serialized[..]) + .unwrap(); + + assert_eq!(&deserialized, value); +} + +mod nullable_enum { + use super::*; + + #[derive(Debug, Serialize, Deserialize, PartialEq)] + enum MyEnum { + A, + B, + } + + #[derive(Debug, Serialize, Deserialize, PartialEq)] + enum MyUnionNullable { + Null, + MyEnum(MyEnum), + } + + #[derive(Debug, Serialize, Deserialize, PartialEq)] + enum MyUnionAvroJsonEncoding { + MyEnum(MyEnum), + } + + fn schema() -> Schema { + Schema::parse_str( + r#" + [ + "null", + { + "type": "enum", + "name": "MyEnum", + "symbols": ["A", "B"] + } + ]"#, + ) + .unwrap() + } + + #[test] + fn null_variant_enum() { + let schema = schema(); + assert_roundtrip(&MyUnionNullable::Null, &schema); + assert_roundtrip(&MyUnionNullable::MyEnum(MyEnum::A), &schema); + assert_roundtrip(&MyUnionNullable::MyEnum(MyEnum::B), &schema); + } + + #[test] + fn option_enum() { + let schema = schema(); + assert_roundtrip(&None::, &schema); + assert_roundtrip(&Some(MyEnum::A), &schema); + assert_roundtrip(&Some(MyEnum::B), &schema); + } + + #[test] + fn avro_json_encoding_compatible_null() { + assert_roundtrip(&None::, &schema()); + } + + #[test] + // TODO: A (union) enum with only one variant is incorrectly seen as an option by the serializer + // and fails to serialize + fn avro_json_encoding_compatible_my_enum_a() { + // I think this should work + assert_roundtrip(&Some(MyUnionAvroJsonEncoding::MyEnum(MyEnum::A)), &schema()); + } +} + +mod nullable_primitive_int { + use super::*; + + #[derive(Debug, Serialize, Deserialize, PartialEq)] + enum MyUnionNullable { + Null, + Int(i32), + } + + #[derive(Debug, Serialize, Deserialize, PartialEq)] + enum MyUnionAvroJsonEncoding { + #[serde(rename = "int")] + Int(i32), + } + + fn schema() -> Schema { + Schema::parse_str( + r#" + [ + "null", + "int" + ] + "#, + ) + .unwrap() + } + + #[test] + fn null_variant_enum() { + let schema = schema(); + assert_roundtrip(&MyUnionNullable::Null, &schema); + assert_roundtrip(&MyUnionNullable::Int(42), &schema); + } + + #[test] + fn option_i32() { + let schema = schema(); + assert_roundtrip(&None::, &schema); + assert_roundtrip(&Some(42_i32), &schema); + } + + #[test] + fn avro_json_encoding_compatible_null() { + assert_roundtrip(&None::, &schema()); + } + + #[test] + // TODO: A (union) enum with only one variant is incorrectly seen as an option by the serializer + // and fails to serialize + fn avro_json_encoding_compatible_int_42() { + assert_roundtrip(&Some(MyUnionAvroJsonEncoding::Int(42)), &schema()); + } +} + +mod nullable_record { + use super::*; + + #[derive(Debug, Serialize, Deserialize, PartialEq)] + struct MyRecord { + a: i32, + } + + #[derive(Debug, Serialize, Deserialize, PartialEq)] + enum MyUnionNullable { + Null, + MyRecord(MyRecord), + } + + #[derive(Debug, Serialize, Deserialize, PartialEq)] + enum MyUnionAvroJsonEncoding { + MyRecord(MyRecord), + } + + fn schema() -> Schema { + Schema::parse_str( + r#" + [ + "null", + { + "type": "record", + "name": "MyRecord", + "fields": [ + {"name": "a", "type": "int"} + ] + } + ] + "#, + ) + .unwrap() + } + + #[test] + fn null_variant_enum() { + let schema = schema(); + assert_roundtrip(&MyUnionNullable::Null, &schema); + assert_roundtrip(&MyUnionNullable::MyRecord(MyRecord { a: 27 }), &schema); + } + + #[test] + fn option_record() { + let schema = schema(); + assert_roundtrip(&None::, &schema); + assert_roundtrip(&Some(MyRecord { a: 27 }), &schema); + } + + #[test] + fn avro_json_encoding_compatible_null() { + assert_roundtrip(&None::, &schema()); + } + + #[test] + // TODO: A (union) enum with only one variant is incorrectly seen as an option by the serializer + // and fails to serialize + fn avro_json_encoding_compatible_my_record_a_27() { + assert_roundtrip( + &Some(MyUnionAvroJsonEncoding::MyRecord(MyRecord { a: 27 })), + &schema(), + ); + } +} + +mod nullable_int_enum_record { + use super::*; + + #[derive(Debug, Serialize, Deserialize, PartialEq)] + enum MyEnum { + A, + B, + } + + #[derive(Debug, Serialize, Deserialize, PartialEq)] + struct MyRecord { + a: i32, + } + + #[derive(Debug, Serialize, Deserialize, PartialEq)] + enum MyUnionNullable { + Null, + Int(i32), + MyEnum(MyEnum), + MyRecord(MyRecord), + } + + #[derive(Debug, Serialize, Deserialize, PartialEq)] + #[serde(untagged)] + enum MyUnionUntagged { + Int(i32), + MyEnum(MyEnum), + MyRecord(MyRecord), + } + + #[derive(Debug, Serialize, Deserialize, PartialEq)] + enum MyUnionAvroJsonEncoding { + Int(i32), + MyEnum(MyEnum), + MyRecord(MyRecord), + } + + fn schema() -> Schema { + Schema::parse_str( + r#" + [ + "null", + "int", + { + "type": "enum", + "name": "MyEnum", + "symbols": ["A", "B"] + }, + { + "type": "record", + "name": "MyRecord", + "fields": [ + {"name": "a", "type": "int"} + ] + } + ] + "#, + ) + .unwrap() + } + + #[test] + fn null_variant_enum() { + let schema = schema(); + assert_roundtrip(&MyUnionNullable::Null, &schema); + assert_roundtrip(&MyUnionNullable::Int(42), &schema); + assert_roundtrip(&MyUnionNullable::MyEnum(MyEnum::A), &schema); + assert_roundtrip(&MyUnionNullable::MyEnum(MyEnum::B), &schema); + assert_roundtrip(&MyUnionNullable::MyRecord(MyRecord { a: 27 }), &schema); + } + + #[test] + fn option_enum() { + let schema = schema(); + assert_roundtrip(&None::, &schema); + assert_roundtrip(&Some(MyUnionAvroJsonEncoding::Int(42)), &schema); + assert_roundtrip(&Some(MyUnionAvroJsonEncoding::MyEnum(MyEnum::A)), &schema); + assert_roundtrip( + &Some(MyUnionAvroJsonEncoding::MyRecord(MyRecord { a: 27 })), + &schema, + ); + } + + #[test] + fn option_enum_untagged() { + let schema = schema(); + assert_roundtrip(&None::, &schema); + assert_roundtrip(&Some(MyUnionUntagged::Int(42)), &schema); + assert_roundtrip( + &Some(MyUnionUntagged::MyRecord(MyRecord { a: 27 })), + &schema, + ); + } + + #[test] + #[should_panic( + expected = "If the type has a plain enum inside a untagged enum, this is not supported by Serde." + )] + fn rusty_untagged_my_enum_a() { + // This cannot work. Because the parent enum is untagged, the serializer will get the index + // of the child enum. This makes it impossible for the serializer to pick the right variant. + assert_roundtrip(&Some(MyUnionUntagged::MyEnum(MyEnum::A)), &schema()); + } +} + +mod nullable_untagged_pitfall { + use super::*; + + #[derive(Debug, Serialize, Deserialize, PartialEq)] + struct MyRecordA { + a: i32, + } + + #[derive(Debug, Serialize, Deserialize, PartialEq)] + struct MyRecordB { + a: i32, + } + + #[derive(Debug, Serialize, Deserialize, PartialEq)] + enum MyUnionNullable { + Null, + MyRecordA(MyRecordA), + MyRecordB(MyRecordB), + } + + #[derive(Debug, Serialize, Deserialize, PartialEq)] + #[serde(untagged)] + enum MyUnionUntagged { + MyRecordA(MyRecordA), + MyRecordB(MyRecordB), + } + + #[derive(Debug, Serialize, Deserialize, PartialEq)] + enum MyUnionAvroJsonEncoding { + MyRecordA(MyRecordA), + MyRecordB(MyRecordB), + } + + fn schema() -> Schema { + Schema::parse_str( + r#" + [ + "null", + { + "type": "record", + "name": "MyRecordA", + "fields": [ + {"name": "a", "type": "int"} + ] + }, + { + "type": "record", + "name": "MyRecordB", + "fields": [ + {"name": "a", "type": "int"} + ] + } + ] + "#, + ) + .unwrap() + } + + #[test] + fn null_variant_enum_my_record_a_27() { + let schema = schema(); + assert_roundtrip(&MyUnionNullable::Null, &schema); + assert_roundtrip(&MyUnionNullable::MyRecordA(MyRecordA { a: 27 }), &schema); + assert_roundtrip(&MyUnionNullable::MyRecordB(MyRecordB { a: 27 }), &schema); + } + + #[test] + fn rusty_my_record_a_27() { + let schema = schema(); + assert_roundtrip(&None::, &schema); + assert_roundtrip( + &Some(MyUnionAvroJsonEncoding::MyRecordA(MyRecordA { a: 27 })), + &schema, + ); + assert_roundtrip( + &Some(MyUnionAvroJsonEncoding::MyRecordB(MyRecordB { a: 27 })), + &schema, + ); + } + + #[test] + fn rusty_untagged_my_record_a_27() { + let schema = schema(); + assert_roundtrip(&None::, &schema); + assert_roundtrip( + &Some(MyUnionUntagged::MyRecordA(MyRecordA { a: 27 })), + &schema, + ); + } + + #[test] + #[should_panic(expected = "assertion failed: `(left == right)`")] + fn rusty_untagged_my_record_b_27() { + // Because the untagged enum has two exactly the same fields, this will be correctly serialized + // as MyRecordB, but incorrectly deserialized as MyRecordA. This is a limitation of Serde untagged. + assert_roundtrip( + &Some(MyUnionUntagged::MyRecordB(MyRecordB { a: 27 })), + &schema(), + ); + } +}