From 7d4e6fd2f8ae14109271ac348aaa87661c770dfc Mon Sep 17 00:00:00 2001 From: Connor Sanders Date: Sat, 28 Dec 2024 12:44:19 -0600 Subject: [PATCH] Added basic support for arrow -> avro codec along with beginnings of avro writer. --- arrow-avro/src/codec.rs | 228 ++++++++++++++++++++++++- arrow-avro/src/lib.rs | 1 + arrow-avro/src/schema.rs | 22 +-- arrow-avro/src/writer/mod.rs | 15 ++ arrow-avro/src/writer/schema.rs | 288 ++++++++++++++++++++++++++++++++ arrow-avro/src/writer/vlq.rs | 98 +++++++++++ 6 files changed, 640 insertions(+), 12 deletions(-) create mode 100644 arrow-avro/src/writer/mod.rs create mode 100644 arrow-avro/src/writer/schema.rs create mode 100644 arrow-avro/src/writer/vlq.rs diff --git a/arrow-avro/src/codec.rs b/arrow-avro/src/codec.rs index 2ac1ad038bd7..25a790fa476a 100644 --- a/arrow-avro/src/codec.rs +++ b/arrow-avro/src/codec.rs @@ -15,10 +15,14 @@ // specific language governing permissions and limitations // under the License. -use crate::schema::{Attributes, ComplexType, PrimitiveType, Record, Schema, TypeName}; +use crate::schema::{ + Attributes, ComplexType, PrimitiveType, Schema, TypeName, Array, Fixed, Map, Record, + Field as AvroFieldDef +}; use arrow_schema::{ ArrowError, DataType, Field, FieldRef, IntervalUnit, SchemaBuilder, SchemaRef, TimeUnit, }; +use arrow_array::{ArrayRef, Int32Array, StringArray, StructArray, RecordBatch}; use std::borrow::Cow; use std::collections::HashMap; use std::sync::Arc; @@ -45,6 +49,25 @@ pub struct AvroDataType { } impl AvroDataType { + + /// Create a new AvroDataType with the given parts. + /// This helps you construct it from outside `codec.rs` without exposing internals. + pub fn new( + codec: Codec, + nullability: Option, + metadata: HashMap, + ) -> Self { + AvroDataType { + codec, + nullability, + metadata, + } + } + + pub fn from_codec(codec: Codec) -> Self { + Self::new(codec, None, Default::default()) + } + /// Returns an arrow [`Field`] with the given name pub fn field_with_name(&self, name: &str) -> Field { let d = self.codec.data_type(); @@ -58,6 +81,23 @@ impl AvroDataType { pub fn nullability(&self) -> Option { self.nullability } + + /// Convert this `AvroDataType`, which encapsulates an Arrow data type (`codec`) + /// plus nullability, back into an Avro `Schema<'a>`. + pub fn to_avro_schema<'a>(&'a self, name: &'a str) -> Schema<'a> { + let inner_schema = self.codec.to_avro_schema(name); + + // If the field is nullable in Arrow, wrap Avro schema in a union: ["null", ]. + // Otherwise, return the schema as-is. + if let Some(_) = self.nullability { + Schema::Union(vec![ + Schema::TypeName(TypeName::Primitive(PrimitiveType::Null)), + inner_schema, + ]) + } else { + inner_schema + } + } } /// A named [`AvroDataType`] @@ -157,6 +197,128 @@ impl Codec { Self::Struct(f) => DataType::Struct(f.iter().map(|x| x.field()).collect()), } } + + /// Convert this `Codec` variant to an Avro `Schema<'a>`. + /// More work needed to handle `decimal`, `enum`, `map`, etc. + pub fn to_avro_schema<'a>(&'a self, name: &'a str) -> Schema<'a> { + match self { + Codec::Null => Schema::TypeName(TypeName::Primitive(PrimitiveType::Null)), + Codec::Boolean => Schema::TypeName(TypeName::Primitive(PrimitiveType::Boolean)), + Codec::Int32 => Schema::TypeName(TypeName::Primitive(PrimitiveType::Int)), + Codec::Int64 => Schema::TypeName(TypeName::Primitive(PrimitiveType::Long)), + Codec::Float32 => Schema::TypeName(TypeName::Primitive(PrimitiveType::Float)), + Codec::Float64 => Schema::TypeName(TypeName::Primitive(PrimitiveType::Double)), + Codec::Binary => Schema::TypeName(TypeName::Primitive(PrimitiveType::Bytes)), + Codec::Utf8 => Schema::TypeName(TypeName::Primitive(PrimitiveType::String)), + + // date32 => Avro int + logicalType=date + Codec::Date32 => Schema::Type(crate::schema::Type { + r#type: TypeName::Primitive(PrimitiveType::Int), + attributes: Attributes { + logical_type: Some("date"), + additional: Default::default(), + }, + }), + + // time-millis => Avro int with logicalType=time-millis + Codec::TimeMillis => Schema::Type(crate::schema::Type { + r#type: TypeName::Primitive(PrimitiveType::Int), + attributes: Attributes { + logical_type: Some("time-millis"), + additional: Default::default(), + }, + }), + + // time-micros => Avro long with logicalType=time-micros + Codec::TimeMicros => Schema::Type(crate::schema::Type { + r#type: TypeName::Primitive(PrimitiveType::Long), + attributes: Attributes { + logical_type: Some("time-micros"), + additional: Default::default(), + }, + }), + + // timestamp-millis => Avro long with logicalType=timestamp-millis + Codec::TimestampMillis(is_utc) => { + // TODO `is_utc` or store it in metadata + Schema::Type(crate::schema::Type { + r#type: TypeName::Primitive(PrimitiveType::Long), + attributes: Attributes { + logical_type: Some("timestamp-millis"), + additional: Default::default(), + }, + }) + } + + // timestamp-micros => Avro long with logicalType=timestamp-micros + Codec::TimestampMicros(is_utc) => { + Schema::Type(crate::schema::Type { + r#type: TypeName::Primitive(PrimitiveType::Long), + attributes: Attributes { + logical_type: Some("timestamp-micros"), + additional: Default::default(), + }, + }) + } + + Codec::Interval => { + Schema::Type(crate::schema::Type { + r#type: TypeName::Primitive(PrimitiveType::Bytes), + attributes: Attributes { + logical_type: Some("duration"), + additional: Default::default(), + }, + }) + } + + Codec::Fixed(size) => { + // Convert Arrow FixedSizeBinary => Avro fixed with a known name & size + // TODO namespace/aliases. + Schema::Complex(ComplexType::Fixed(Fixed { + name, + namespace: None, // TODO namespace implementation + aliases: vec![], // TODO alias implementation + size: *size as usize, + attributes: Attributes::default(), + })) + } + + Codec::List(item_type) => { + // Avro array with "items" recursively derived + let items_schema = item_type.to_avro_schema("items"); + Schema::Complex(ComplexType::Array(Array { + items: Box::new(items_schema), + attributes: Attributes::default(), + })) + } + + Codec::Struct(fields) => { + // Avro record with nested fields + let record_fields = fields + .iter() + .map(|f| { + // For each `AvroField`, get its Avro schema + let child_schema = f.data_type().to_avro_schema(f.name()); + AvroFieldDef { + name: f.name(), // Avro field name + doc: None, + r#type: child_schema, + default: None, + } + }) + .collect(); + + Schema::Complex(ComplexType::Record(Record { + name, + namespace: None, // TODO follow up for namespace implementation + doc: None, + aliases: vec![], // TODO follow up for alias implementation + fields: record_fields, + attributes: Attributes::default(), + })) + } + } + } } impl From for Codec { @@ -327,3 +489,67 @@ fn make_data_type<'a>( } } } + + +/// Convert an Arrow `Field` into an `AvroField`. +pub(crate) fn arrow_field_to_avro_field(arrow_field: &Field) -> AvroField { + // TODO advanced metadata logic here + let codec = arrow_type_to_codec(arrow_field.data_type()); + // Set nullability if the Arrow field is nullable + let nullability = if arrow_field.is_nullable() { + Some(Nullability::NullFirst) + } else { + None + }; + let avro_data_type = AvroDataType { + nullability, + metadata: arrow_field.metadata().clone(), + codec, + }; + AvroField { + name: arrow_field.name().clone(), + data_type: avro_data_type, + } +} + +/// Maps an Arrow `DataType` to a `Codec`: +fn arrow_type_to_codec(dt: &DataType) -> Codec { + use arrow_schema::DataType::*; + match dt { + Null => Codec::Null, + Boolean => Codec::Boolean, + Int8 | Int16 | Int32 => Codec::Int32, + Int64 => Codec::Int64, + Float32 => Codec::Float32, + Float64 => Codec::Float64, + Utf8 => Codec::Utf8, + Binary | LargeBinary => Codec::Binary, + Date32 => Codec::Date32, + Time32(TimeUnit::Millisecond) => Codec::TimeMillis, + Time64(TimeUnit::Microsecond) => Codec::TimeMicros, + Timestamp(TimeUnit::Millisecond, _) => Codec::TimestampMillis(true), + Timestamp(TimeUnit::Microsecond, _) => Codec::TimestampMicros(true), + FixedSizeBinary(n) => Codec::Fixed(*n as i32), + + List(field) => { + // Recursively create Codec for the child item + let child_codec = arrow_type_to_codec(field.data_type()); + Codec::List(Arc::new(AvroDataType { + nullability: None, + metadata: Default::default(), + codec: child_codec, + })) + } + Struct(child_fields) => { + let avro_fields: Vec = child_fields + .iter() + .map(|fref| arrow_field_to_avro_field(fref.as_ref())) + .collect(); + Codec::Struct(Arc::from(avro_fields)) + } + _ => { + // TODO handle more arrow types (e.g. decimal, map, union, etc.) + Codec::Utf8 + } + } +} diff --git a/arrow-avro/src/lib.rs b/arrow-avro/src/lib.rs index d01d681b7af0..ef3bd082d0e8 100644 --- a/arrow-avro/src/lib.rs +++ b/arrow-avro/src/lib.rs @@ -29,6 +29,7 @@ mod schema; mod compression; mod codec; +mod writer; #[cfg(test)] mod test_util { diff --git a/arrow-avro/src/schema.rs b/arrow-avro/src/schema.rs index a9d91e47948b..4895d24d76e4 100644 --- a/arrow-avro/src/schema.rs +++ b/arrow-avro/src/schema.rs @@ -123,7 +123,7 @@ pub enum ComplexType<'a> { pub struct Record<'a> { #[serde(borrow)] pub name: &'a str, - #[serde(borrow, default)] + #[serde(borrow, default, skip_serializing_if = "Option::is_none")] pub namespace: Option<&'a str>, #[serde(borrow, default)] pub doc: Option<&'a str>, @@ -144,7 +144,7 @@ pub struct Field<'a> { pub doc: Option<&'a str>, #[serde(borrow)] pub r#type: Schema<'a>, - #[serde(borrow, default)] + #[serde(borrow, default, skip_serializing_if = "Option::is_none")] pub default: Option<&'a str>, } @@ -155,7 +155,7 @@ pub struct Field<'a> { pub struct Enum<'a> { #[serde(borrow)] pub name: &'a str, - #[serde(borrow, default)] + #[serde(borrow, default, skip_serializing_if = "Option::is_none")] pub namespace: Option<&'a str>, #[serde(borrow, default)] pub doc: Option<&'a str>, @@ -163,7 +163,7 @@ pub struct Enum<'a> { pub aliases: Vec<&'a str>, #[serde(borrow)] pub symbols: Vec<&'a str>, - #[serde(borrow, default)] + #[serde(borrow, default, skip_serializing_if = "Option::is_none")] pub default: Option<&'a str>, #[serde(flatten)] pub attributes: Attributes<'a>, @@ -198,7 +198,7 @@ pub struct Map<'a> { pub struct Fixed<'a> { #[serde(borrow)] pub name: &'a str, - #[serde(borrow, default)] + #[serde(borrow, default, skip_serializing_if = "Option::is_none")] pub namespace: Option<&'a str>, #[serde(borrow, default)] pub aliases: Vec<&'a str>, @@ -237,7 +237,7 @@ mod tests { "logicalType":"timestamp-micros" }"#, ) - .unwrap(); + .unwrap(); let timestamp = Type { r#type: TypeName::Primitive(PrimitiveType::Long), @@ -260,7 +260,7 @@ mod tests { "scale":2 }"#, ) - .unwrap(); + .unwrap(); let decimal = ComplexType::Fixed(Fixed { name: "fixed", @@ -300,7 +300,7 @@ mod tests { ] }"#, ) - .unwrap(); + .unwrap(); assert_eq!( schema, @@ -333,7 +333,7 @@ mod tests { ] }"#, ) - .unwrap(); + .unwrap(); assert_eq!( schema, @@ -392,7 +392,7 @@ mod tests { ] }"#, ) - .unwrap(); + .unwrap(); assert_eq!( schema, @@ -453,7 +453,7 @@ mod tests { ] }"#, ) - .unwrap(); + .unwrap(); assert_eq!( schema, diff --git a/arrow-avro/src/writer/mod.rs b/arrow-avro/src/writer/mod.rs new file mode 100644 index 000000000000..afb623162d9a --- /dev/null +++ b/arrow-avro/src/writer/mod.rs @@ -0,0 +1,15 @@ +mod schema; +mod vlq; + +#[cfg(test)] +mod test { + use std::fs::File; + use std::io::BufWriter; + use arrow_array::RecordBatch; + + fn write_file(file: &str, batch: &RecordBatch) { + let file = File::open(file).unwrap(); + let mut writer = BufWriter::new(file); + + } +} \ No newline at end of file diff --git a/arrow-avro/src/writer/schema.rs b/arrow-avro/src/writer/schema.rs new file mode 100644 index 000000000000..c8cc5a7f9ec2 --- /dev/null +++ b/arrow-avro/src/writer/schema.rs @@ -0,0 +1,288 @@ +use std::collections::HashMap; +use std::sync::Arc; +use arrow_array::RecordBatch; +use crate::codec::{AvroDataType, AvroField, Codec}; +use crate::schema::Schema; + +fn record_batch_to_avro_schema<'a>( + batch: &'a RecordBatch, + record_name: &'a str, + top_level_data_type: &'a AvroDataType, +) -> Schema<'a> { + top_level_data_type.to_avro_schema(record_name) +} + +pub fn to_avro_json_schema( + batch: &RecordBatch, + record_name: &str, +) -> Result { + let avro_fields: Vec = batch + .schema() + .fields() + .iter() + .map(|arrow_field| crate::codec::arrow_field_to_avro_field(arrow_field)) + .collect(); + let top_level_data_type = AvroDataType::from_codec( + Codec::Struct(Arc::from(avro_fields)), + ); + let avro_schema = record_batch_to_avro_schema(batch, record_name, &top_level_data_type); + serde_json::to_string_pretty(&avro_schema) +} + +#[cfg(test)] +mod tests { + use super::*; + use arrow_array::{Int32Array, StringArray, RecordBatch, ArrayRef, StructArray}; + use arrow_schema::{DataType, Field, Fields, Schema as ArrowSchema}; + use serde_json::{json, Value}; + use std::sync::Arc; + + #[test] + fn test_record_batch_to_avro_schema_basic() { + let arrow_schema = Arc::new(ArrowSchema::new(vec![ + Field::new("id", DataType::Int32, false), + Field::new("name", DataType::Utf8, true), + ])); + + let col_id = Arc::new(Int32Array::from(vec![1, 2, 3])); + let col_name = Arc::new(StringArray::from(vec![Some("foo"), None, Some("bar")])); + let batch = RecordBatch::try_new(arrow_schema, vec![col_id, col_name]) + .expect("Failed to create RecordBatch"); + + // Convert the batch -> Avro `Schema` + let avro_schema = to_avro_json_schema(&batch, "MyTestRecord") + .expect("Failed to convert RecordBatch to Avro JSON schema");; + let actual_json: Value = serde_json::from_str(&avro_schema) + .expect("Invalid JSON returned by to_avro_json_schema"); + + let expected_json = json!({ + "type": "record", + "name": "MyTestRecord", + "aliases": [], + "doc": null, + "logicalType": null, + "fields": [ + { + "name": "id", + "doc": null, + "type": "int" + }, + { + "name": "name", + "doc": null, + "type": ["null", "string"] + } + ] + }); + + // Compare the two JSON objects + assert_eq!( + actual_json, expected_json, + "Avro Schema JSON does not match expected" + ); + } + + #[test] + fn test_to_avro_json_schema_basic() { + let arrow_schema = Arc::new(ArrowSchema::new(vec![ + Field::new("id", DataType::Int32, false), + Field::new("desc", DataType::Utf8, true), + ])); + + let col_id = Arc::new(Int32Array::from(vec![10, 20, 30])); + let col_desc = Arc::new(StringArray::from(vec![Some("a"), Some("b"), None])); + let batch = RecordBatch::try_new(arrow_schema, vec![col_id, col_desc]) + .expect("Failed to create RecordBatch"); + + let json_schema_string = to_avro_json_schema(&batch, "AnotherTestRecord") + .expect("Failed to convert RecordBatch to Avro JSON schema"); + + let actual_json: Value = serde_json::from_str(&json_schema_string) + .expect("Invalid JSON returned by to_avro_json_schema"); + + let expected_json = json!({ + "type": "record", + "name": "AnotherTestRecord", + "aliases": [], + "doc": null, + "logicalType": null, + "fields": [ + { + "name": "id", + "type": "int", + "doc": null, + }, + { + "name": "desc", + "type": ["null", "string"], + "doc": null, + } + ] + }); + + assert_eq!( + actual_json, expected_json, + "JSON schema mismatch for to_avro_json_schema" + ); + } + + #[test] + fn test_to_avro_json_schema_single_nonnull_int() { + let arrow_schema = Arc::new(arrow_schema::Schema::new(vec![Field::new("id", DataType::Int32, false)])); + + let col_id = Arc::new(Int32Array::from(vec![1, 2, 3])); + let batch = RecordBatch::try_new(arrow_schema, vec![col_id]) + .expect("Failed to create RecordBatch"); + + let avro_json_string = to_avro_json_schema(&batch, "MySingleIntRecord") + .expect("Failed to generate Avro JSON schema"); + + let actual_json: Value = serde_json::from_str(&avro_json_string) + .expect("Failed to parse Avro JSON schema"); + + let expected_json = json!({ + "type": "record", + "name": "MySingleIntRecord", + "aliases": [], + "doc": null, + "logicalType": null, + "fields": [ + { + "name": "id", + "type": "int", + "doc": null, + } + ] + }); + + // Compare + assert_eq!(actual_json, expected_json, "Avro JSON schema mismatch"); + } + + #[test] + fn test_to_avro_json_schema_two_fields_nullable_string() { + let arrow_schema = Arc::new(arrow_schema::Schema::new(vec![ + Field::new("id", DataType::Int32, false), + Field::new("name", DataType::Utf8, true), + ])); + + let col_id = Arc::new(Int32Array::from(vec![1, 2, 3])); + let col_name = Arc::new(StringArray::from(vec![Some("foo"), None, Some("bar")])); + let batch = RecordBatch::try_new(arrow_schema, vec![col_id, col_name]) + .expect("Failed to create RecordBatch"); + + let avro_json_string = to_avro_json_schema(&batch, "MyRecord") + .expect("Failed to generate Avro JSON schema"); + + let actual_json: Value = serde_json::from_str(&avro_json_string) + .expect("Failed to parse Avro JSON schema"); + + let expected_json = json!({ + "type": "record", + "name": "MyRecord", + "aliases": [], + "doc": null, + "logicalType": null, + "fields": [ + { + "name": "id", + "type": "int", + "doc": null, + }, + { + "name": "name", + "doc": null, + "type": [ + "null", + "string", + ] + } + ] + }); + + // Compare + assert_eq!(actual_json, expected_json, "Avro JSON schema mismatch"); + } + + #[test] + fn test_to_avro_json_schema_nested_struct() { + let inner_fields = Fields::from(vec![ + Field::new("inner_int", DataType::Int32, false), + Field::new("inner_str", DataType::Utf8, true), + ]); + + let arrow_schema = Arc::new(arrow_schema::Schema::new(vec![ + Field::new("my_struct", DataType::Struct(inner_fields), true) + ])); + + let inner_int_col = Arc::new(Int32Array::from(vec![10, 20, 30])) as ArrayRef; + let inner_str_col = Arc::new(StringArray::from(vec![Some("a"), None, Some("c")])) as ArrayRef; + + let fields_arrays = vec![ + ( + Arc::new(Field::new("inner_int", DataType::Int32, false)), + inner_int_col, + ), + ( + Arc::new(Field::new("inner_str", DataType::Utf8, true)), + inner_str_col, + ), + ]; + + let struct_array = StructArray::from(fields_arrays); + + let batch = RecordBatch::try_new( + arrow_schema, + vec![Arc::new(struct_array)], + ) + .expect("Failed to create RecordBatch"); + + let avro_json_string = to_avro_json_schema(&batch, "NestedRecord") + .expect("Failed to generate Avro JSON schema"); + + let actual_json: Value = serde_json::from_str(&avro_json_string) + .expect("Failed to parse Avro JSON schema"); + + let expected_json = json!({ + "type": "record", + "name": "NestedRecord", + "aliases": [], + "doc": null, + "logicalType": null, + "fields": [ + { + "name": "my_struct", + "doc": null, + "type": [ + "null", + { + "type": "record", + "name": "my_struct", + "aliases": [], + "doc": null, + "logicalType": null, + "fields": [ + { + "name": "inner_int", + "type": "int", + "doc": null, + }, + { + "name": "inner_str", + "doc": null, + "type": [ + "null", + "string", + ] + } + ] + } + ] + } + ] + }); + + // Compare + assert_eq!(actual_json, expected_json, "Avro JSON schema mismatch"); + } +} diff --git a/arrow-avro/src/writer/vlq.rs b/arrow-avro/src/writer/vlq.rs new file mode 100644 index 000000000000..765e6687abaa --- /dev/null +++ b/arrow-avro/src/writer/vlq.rs @@ -0,0 +1,98 @@ +/// Encoder for zig-zag encoded variable length integers +/// +/// This complements the VLQ decoding logic used by Avro. Zig-zag encoding maps signed integers +/// to unsigned integers so that small magnitudes (both positive and negative) produce smaller varints. +/// After zig-zag encoding, values are encoded as a series of bytes where the lower 7 bits are data +/// and the high bit indicates if another byte follows. +/// +/// See also: +/// +/// +#[derive(Debug, Default)] +pub struct VLQEncoder; + +impl VLQEncoder { + /// Encode a signed 64-bit integer `value` into `output` using Avro's zig-zag varint encoding. + /// + /// Zig-zag encoding: + /// ```text + /// encoded = (value << 1) ^ (value >> 63) + /// ``` + /// + /// Then `encoded` is written as a variable-length integer (varint): + /// - Extract 7 bits at a time + /// - If more bits remain, set the MSB of the current byte to 1 and continue + /// - Otherwise, MSB is 0 and this is the last byte + pub fn long(&mut self, value: i64, output: &mut Vec) { + let zigzag = ((value << 1) ^ (value >> 63)) as u64; + self.encode_varint(zigzag, output); + } + + fn encode_varint(&self, mut val: u64, output: &mut Vec) { + while (val & !0x7F) != 0 { + output.push(((val & 0x7F) as u8) | 0x80); + val >>= 7; + } + output.push(val as u8); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn decode_varint(buf: &mut &[u8]) -> Option { + let mut value = 0_u64; + for i in 0..10 { + let b = buf.get(i).copied()?; + let lower_7 = (b & 0x7F) as u64; + value |= lower_7 << (7 * i); + if b & 0x80 == 0 { + *buf = &buf[i + 1..]; + return Some(value); + } + } + None // more than 10 bytes or not terminated properly + } + + fn decode_zigzag(val: u64) -> i64 { + ((val >> 1) as i64) ^ -((val & 1) as i64) + } + + fn decode_long(buf: &mut &[u8]) -> Option { + let val = decode_varint(buf)?; + Some(decode_zigzag(val)) + } + + fn round_trip(value: i64) { + let mut encoder = VLQEncoder::default(); + let mut buf = Vec::new(); + encoder.long(value, &mut buf); + + let mut slice = buf.as_slice(); + let decoded = decode_long(&mut slice).expect("Failed to decode value"); + assert_eq!(decoded, value, "Round-trip mismatch for value {}", value); + assert!(slice.is_empty(), "Not all bytes consumed"); + } + + #[test] + fn test_round_trip() { + round_trip(0); + round_trip(1); + round_trip(-1); + round_trip(12345678); + round_trip(-12345678); + round_trip(i64::MAX); + round_trip(i64::MIN); + } + + #[test] + fn test_random_values() { + use rand::Rng; + let mut rng = rand::thread_rng(); + for _ in 0..1000 { + let val: i64 = rng.gen(); + round_trip(val); + } + } +}