1use std::{collections::HashSet, fmt, sync::Arc};
6
7use arrow::datatypes::{DataType, Field, FieldRef, Fields, Schema, SchemaRef, TimeUnit};
8
9use serde::{Deserialize, Serialize};
10use snafu::prelude::*;
11
12#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
14pub enum LogicalTimestampUnit {
15 Millis,
17 Micros,
19 Nanos,
21}
22
23impl LogicalTimestampUnit {
24 fn to_arrow_time_unit(self) -> TimeUnit {
25 match self {
26 LogicalTimestampUnit::Millis => TimeUnit::Millisecond,
27 LogicalTimestampUnit::Micros => TimeUnit::Microsecond,
28 LogicalTimestampUnit::Nanos => TimeUnit::Nanosecond,
29 }
30 }
31}
32
33impl fmt::Display for LogicalTimestampUnit {
34 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
35 match self {
36 LogicalTimestampUnit::Millis => write!(f, "ms"),
37 LogicalTimestampUnit::Micros => write!(f, "us"),
38 LogicalTimestampUnit::Nanos => write!(f, "ns"),
39 }
40 }
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
45pub struct LogicalField {
46 pub name: String,
48 pub data_type: LogicalDataType,
50 pub nullable: bool,
52}
53
54impl LogicalField {
55 fn to_arrow_field_ref(&self, path: &str) -> Result<FieldRef, SchemaConvertError> {
56 let dt = self.data_type.to_arrow_datatype(path)?;
57 Ok(Arc::new(Field::new(self.name.clone(), dt, self.nullable)))
58 }
59}
60
61impl fmt::Display for LogicalField {
62 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63 if self.nullable {
64 write!(f, "{}?: {}", self.name, self.data_type)
65 } else {
66 write!(f, "{}: {}", self.name, self.data_type)
67 }
68 }
69}
70
71fn join_path(parent: &str, child: &str) -> String {
72 if parent.is_empty() {
73 child.to_string()
74 } else {
75 format!("{parent}.{child}")
76 }
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
81pub enum LogicalDataType {
82 Bool,
84 Int32,
86 Int64,
88 UInt64,
90 Float32,
92 Float64,
94 Binary,
96 FixedBinary {
98 byte_width: i32,
100 },
101 Utf8,
103 Int96,
105
106 Timestamp {
108 unit: LogicalTimestampUnit,
110 timezone: Option<String>, },
113
114 Decimal {
116 precision: i32,
118 scale: i32,
120 },
121
122 Struct {
124 fields: Vec<LogicalField>,
126 },
127
128 List {
130 elements: Box<LogicalField>,
132 },
133
134 Map {
137 key: Box<LogicalField>,
139 value: Option<Box<LogicalField>>,
141 keys_sorted: bool,
143 },
144
145 Other(String),
147}
148
149impl LogicalDataType {
150 fn to_arrow_datatype(&self, column: &str) -> Result<DataType, SchemaConvertError> {
151 Ok(match self {
152 LogicalDataType::Bool => DataType::Boolean,
153 LogicalDataType::Int32 => DataType::Int32,
154 LogicalDataType::Int64 => DataType::Int64,
155 LogicalDataType::UInt64 => DataType::UInt64,
156 LogicalDataType::Float32 => DataType::Float32,
157 LogicalDataType::Float64 => DataType::Float64,
158 LogicalDataType::Binary => DataType::Binary,
159 LogicalDataType::Utf8 => DataType::Utf8,
160
161 LogicalDataType::FixedBinary { byte_width } => {
162 if *byte_width <= 0 {
163 return Err(SchemaConvertError::FixedBinaryInvalidWidth {
164 column: column.to_string(),
165 byte_width: *byte_width,
166 });
167 }
168 DataType::FixedSizeBinary(*byte_width)
169 }
170
171 LogicalDataType::Timestamp { unit, timezone } => {
172 let tz: Option<Arc<str>> = timezone.as_ref().map(|s| Arc::<str>::from(s.as_str()));
173 DataType::Timestamp(unit.to_arrow_time_unit(), tz)
174 }
175
176 LogicalDataType::Int96 => {
177 return Err(SchemaConvertError::Int96Unsupported {
178 column: column.to_string(),
179 });
180 }
181
182 LogicalDataType::Decimal { precision, scale } => {
183 let precision = *precision;
184 let scale = *scale;
185 if precision <= 0 {
186 return Err(SchemaConvertError::DecimalInvalid {
187 column: column.to_string(),
188 precision,
189 scale,
190 details: "precision must be > 0".to_string(),
191 });
192 }
193 if scale < 0 {
194 return Err(SchemaConvertError::DecimalInvalid {
195 column: column.to_string(),
196 precision,
197 scale,
198 details: "scale must be >= 0".to_string(),
199 });
200 }
201 if scale > precision {
202 return Err(SchemaConvertError::DecimalInvalid {
203 column: column.to_string(),
204 precision,
205 scale,
206 details: "scale must be <= precision".to_string(),
207 });
208 }
209
210 if precision <= 38 {
211 DataType::Decimal128(precision as u8, scale as i8)
212 } else if precision <= 76 {
213 DataType::Decimal256(precision as u8, scale as i8)
214 } else {
215 return Err(SchemaConvertError::DecimalInvalid {
216 column: column.to_string(),
217 precision,
218 scale,
219 details: "precision exceeds Arrow maximum (76 digits)".to_string(),
220 });
221 }
222 }
223
224 LogicalDataType::Struct { fields } => {
225 let mut arrow_children: Vec<FieldRef> = Vec::with_capacity(fields.len());
226 for f in fields {
227 let child_path = join_path(column, &f.name);
228 arrow_children.push(f.to_arrow_field_ref(&child_path)?);
229 }
230 DataType::Struct(Fields::from(arrow_children))
231 }
232
233 LogicalDataType::List { elements } => {
234 let child_path = join_path(column, &elements.name);
235 let element_field = elements.to_arrow_field_ref(&child_path)?;
236 DataType::List(element_field)
237 }
238
239 LogicalDataType::Map {
240 key,
241 value,
242 keys_sorted,
243 } => {
244 if key.nullable {
245 return Err(SchemaConvertError::MapKeyMustBeNonNull {
246 column: column.to_string(),
247 });
248 }
249
250 let key_path = format!("{column}.key");
252 let val_path = format!("{column}.value");
253
254 let key_dt = key.data_type.to_arrow_datatype(&key_path)?;
255
256 let (val_dt, val_nullable) = match value.as_deref() {
257 Some(v) => (v.data_type.to_arrow_datatype(&val_path)?, v.nullable),
258 None => (DataType::Null, true),
259 };
260
261 let key_field: FieldRef = Arc::new(Field::new("key", key_dt, false));
262 let val_field: FieldRef = Arc::new(Field::new("value", val_dt, val_nullable));
263
264 let entries_dt = DataType::Struct(Fields::from(vec![key_field, val_field]));
265 let entries_field: FieldRef = Arc::new(Field::new("entries", entries_dt, false));
266
267 DataType::Map(entries_field, *keys_sorted)
268 }
269
270 LogicalDataType::Other(name) => {
271 return Err(SchemaConvertError::OtherTypeUnsupported {
272 column: column.to_string(),
273 name: name.clone(),
274 });
275 }
276 })
277 }
278}
279
280impl fmt::Display for LogicalDataType {
281 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
282 match self {
283 LogicalDataType::Bool => write!(f, "bool"),
284 LogicalDataType::Int32 => write!(f, "int32"),
285 LogicalDataType::Int64 => write!(f, "int64"),
286 LogicalDataType::UInt64 => write!(f, "uint64"),
287 LogicalDataType::Float32 => write!(f, "float32"),
288 LogicalDataType::Float64 => write!(f, "float64"),
289 LogicalDataType::Binary => write!(f, "binary"),
290 LogicalDataType::FixedBinary { byte_width } => write!(f, "fixed_binary[{byte_width}]"),
291 LogicalDataType::Utf8 => write!(f, "utf8"),
292 LogicalDataType::Int96 => write!(f, "int96"),
293
294 LogicalDataType::Timestamp { unit, timezone } => match timezone {
295 Some(tz) => write!(f, "timestamp[{}]({})", unit, tz),
296 None => write!(f, "timestamp[{}]", unit),
297 },
298
299 LogicalDataType::Decimal { precision, scale } => {
300 write!(f, "decimal(precision={precision}, scale={scale})")
301 }
302
303 LogicalDataType::Struct { fields } => {
304 write!(f, "Struct{{")?;
305 for (i, field) in fields.iter().enumerate() {
306 if i > 0 {
307 write!(f, ", ")?;
308 }
309 write!(f, "{}", field)?;
310 }
311 write!(f, "}}")
312 }
313
314 LogicalDataType::List { elements } => {
315 write!(f, "List<{}>", elements)
316 }
317
318 LogicalDataType::Map {
319 key,
320 value,
321 keys_sorted,
322 } => match value.as_deref() {
323 Some(v) => write!(f, "Map<{}, {}, keys_sorted={}>", key, v, keys_sorted),
324 None => write!(
325 f,
326 "Map<{}, value=omitted, keys_sorted={}>",
327 key, keys_sorted
328 ),
329 },
330
331 LogicalDataType::Other(s) => write!(f, "{s}"),
332 }
333 }
334}
335
336#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
338pub struct LogicalSchema {
339 columns: Vec<LogicalField>,
341}
342
343impl LogicalSchema {
344 pub fn to_arrow_schema(&self) -> Result<Schema, SchemaConvertError> {
349 let mut fields = Vec::with_capacity(self.columns.len());
350 for c in &self.columns {
351 let fref = c.to_arrow_field_ref(&c.name)?;
352 fields.push(fref.as_ref().clone());
353 }
354
355 Ok(Schema::new(fields))
356 }
357
358 pub fn to_arrow_schema_ref(&self) -> Result<SchemaRef, SchemaConvertError> {
362 Ok(Arc::new(self.to_arrow_schema()?))
363 }
364}
365
366#[derive(Debug, Clone, Snafu, PartialEq, Eq)]
368pub enum LogicalSchemaError {
369 #[snafu(display("Duplicate column name: {column}"))]
371 DuplicateColumn {
372 column: String,
374 },
375
376 #[snafu(display(
378 "invalid FixedBinary byte_width for column '{column}': {byte_width} (must be > 0)"
379 ))]
380 FixedBinaryInvalidWidthInSchema {
381 column: String,
383 byte_width: i32,
385 },
386
387 #[snafu(display(
389 "FIXED_LEN_BYTE_ARRAY column '{column}' missing type_length in Parquet schema"
390 ))]
391 FixedBinaryMissingLength {
392 column: String,
394 },
395
396 #[snafu(display("Duplicate field name: column={column_path}, field={field}"))]
398 DuplicatedFieldName {
399 column_path: String,
401 field: String,
403 },
404
405 #[snafu(display("Invalid Map Key: map key should not be null for column={column_path}"))]
407 InvalidMapKeyNullability {
408 column_path: String,
410 },
411
412 #[snafu(display("Struct must have at least one field: column={column_path}"))]
414 EmptyStruct {
415 column_path: String,
417 },
418
419 #[snafu(display("List element field name must be non-empty: column={column_path}"))]
421 ListElementNameEmpty {
422 column_path: String,
424 },
425
426 #[snafu(display("Struct field name must be non-empty: column={column_path}, field={field}"))]
428 StructFieldNameEmpty {
429 column_path: String,
431 field: String,
433 },
434
435 #[snafu(display("Unsupported Parquet LIST encoding: column={column_path}, details={details}"))]
437 UnsupportedParquetListEncoding {
438 column_path: String,
440 details: String,
442 },
443
444 #[snafu(display("Unsupported Parquet MAP encoding: column={column_path}, details={details}"))]
446 UnsupportedParquetMapEncoding {
447 column_path: String,
449 details: String,
451 },
452}
453
454impl LogicalSchema {
455 pub fn new(columns: Vec<LogicalField>) -> Result<Self, LogicalSchemaError> {
457 let mut seen = HashSet::new();
458 for col in &columns {
459 if !seen.insert(col.name.clone()) {
460 return DuplicateColumnSnafu {
461 column: col.name.clone(),
462 }
463 .fail();
464 }
465 validate_field(col, &col.name)?;
466 }
467
468 Ok(Self { columns })
469 }
470
471 pub fn columns(&self) -> &[LogicalField] {
473 &self.columns
474 }
475}
476
477fn validate_field(field: &LogicalField, path: &str) -> Result<(), LogicalSchemaError> {
478 validate_dtype(&field.data_type, path)
479}
480
481fn validate_dtype(dt: &LogicalDataType, path: &str) -> Result<(), LogicalSchemaError> {
482 match dt {
483 LogicalDataType::FixedBinary { byte_width } => {
484 if *byte_width <= 0 {
485 return Err(LogicalSchemaError::FixedBinaryInvalidWidthInSchema {
486 column: path.to_string(),
487 byte_width: *byte_width,
488 });
489 }
490 Ok(())
491 }
492
493 LogicalDataType::Struct { fields } => {
494 if fields.is_empty() {
495 return Err(LogicalSchemaError::EmptyStruct {
496 column_path: path.to_string(),
497 });
498 }
499
500 let mut seen = HashSet::with_capacity(fields.len());
501 for child in fields {
502 if child.name.trim().is_empty() {
503 return Err(LogicalSchemaError::StructFieldNameEmpty {
504 column_path: path.to_string(),
505 field: child.name.clone(),
506 });
507 }
508
509 if !seen.insert(child.name.clone()) {
510 return Err(LogicalSchemaError::DuplicatedFieldName {
511 column_path: path.to_string(),
512 field: child.name.clone(),
513 });
514 }
515 let child_path = format!("{}.{}", path, child.name);
516 validate_field(child, &child_path)?;
517 }
518 Ok(())
519 }
520
521 LogicalDataType::List { elements } => {
522 if elements.name.trim().is_empty() {
523 return Err(LogicalSchemaError::ListElementNameEmpty {
524 column_path: path.to_string(),
525 });
526 }
527 let child_path = format!("{}.{}", path, elements.name);
528 validate_field(elements, &child_path)
529 }
530
531 LogicalDataType::Map { key, value, .. } => {
532 if key.nullable {
533 return Err(LogicalSchemaError::InvalidMapKeyNullability {
534 column_path: path.to_string(),
535 });
536 }
537 validate_field(key, &format!("{}.key", path))?;
538 if let Some(v) = value.as_deref() {
539 validate_field(v, &format!("{}.value", path))?;
540 }
541
542 Ok(())
543 }
544
545 _ => Ok(()),
546 }
547}
548
549#[derive(Debug, Snafu)]
551pub enum SchemaConvertError {
552 #[snafu(display("unsupported logical type for column '{column}': {type_name} ({details})"))]
554 UnsupportedLogicalType {
555 column: String,
557 type_name: String,
559 details: String,
561 },
562
563 #[snafu(display(
565 "invalid FixedBinary byte_width for column '{column}': {byte_width} (must be > 0)"
566 ))]
567 FixedBinaryInvalidWidth {
568 column: String,
570 byte_width: i32,
572 },
573
574 #[snafu(display("Int96 is not supported in v0.1 for column '{column}'"))]
576 Int96Unsupported {
577 column: String,
579 },
580
581 #[snafu(display("Other type '{name}' is not supported in v0.1 for column '{column}'"))]
583 OtherTypeUnsupported {
584 column: String,
586 name: String,
588 },
589
590 #[snafu(display(
592 "invalid decimal definition for column '{column}': precision={precision}, scale={scale} ({details})"
593 ))]
594 DecimalInvalid {
595 column: String,
597 precision: i32,
599 scale: i32,
601 details: String,
603 },
604
605 #[snafu(display("map key must be non-nullable for column '{column}'"))]
607 MapKeyMustBeNonNull {
608 column: String,
610 },
611}
612
613#[cfg(test)]
614mod tests {
615 use super::*;
616
617 fn sample_logical_schema_all_supported() -> LogicalSchema {
618 LogicalSchema::new(vec![
619 LogicalField {
620 name: "flag".to_string(),
621 data_type: LogicalDataType::Bool,
622 nullable: false,
623 },
624 LogicalField {
625 name: "i32".to_string(),
626 data_type: LogicalDataType::Int32,
627 nullable: false,
628 },
629 LogicalField {
630 name: "i64".to_string(),
631 data_type: LogicalDataType::Int64,
632 nullable: true,
633 },
634 LogicalField {
635 name: "f32".to_string(),
636 data_type: LogicalDataType::Float32,
637 nullable: false,
638 },
639 LogicalField {
640 name: "f64".to_string(),
641 data_type: LogicalDataType::Float64,
642 nullable: true,
643 },
644 LogicalField {
645 name: "text".to_string(),
646 data_type: LogicalDataType::Utf8,
647 nullable: true,
648 },
649 LogicalField {
650 name: "bytes".to_string(),
651 data_type: LogicalDataType::Binary,
652 nullable: true,
653 },
654 LogicalField {
655 name: "fixed".to_string(),
656 data_type: LogicalDataType::FixedBinary { byte_width: 16 },
657 nullable: false,
658 },
659 LogicalField {
660 name: "ts".to_string(),
661 data_type: LogicalDataType::Timestamp {
662 unit: LogicalTimestampUnit::Micros,
663 timezone: Some("UTC".to_string()),
664 },
665 nullable: false,
666 },
667 ])
668 .expect("valid logical schema")
669 }
670
671 #[test]
672 fn logical_schema_to_arrow_schema_happy_path() {
673 let logical = sample_logical_schema_all_supported();
674 let schema = logical.to_arrow_schema().expect("arrow schema conversion");
675
676 let expected = Schema::new(vec![
677 Field::new("flag", DataType::Boolean, false),
678 Field::new("i32", DataType::Int32, false),
679 Field::new("i64", DataType::Int64, true),
680 Field::new("f32", DataType::Float32, false),
681 Field::new("f64", DataType::Float64, true),
682 Field::new("text", DataType::Utf8, true),
683 Field::new("bytes", DataType::Binary, true),
684 Field::new("fixed", DataType::FixedSizeBinary(16), false),
685 Field::new(
686 "ts",
687 DataType::Timestamp(TimeUnit::Microsecond, Some(Arc::<str>::from("UTC"))),
688 false,
689 ),
690 ]);
691
692 assert_eq!(schema, expected);
693 }
694
695 #[test]
696 fn logical_schema_rejects_fixed_binary_invalid_width() {
697 for width in [0, -1] {
698 let err = LogicalSchema::new(vec![LogicalField {
699 name: "bad_fixed".to_string(),
700 data_type: LogicalDataType::FixedBinary { byte_width: width },
701 nullable: false,
702 }])
703 .expect_err("expected invalid schema to be rejected");
704
705 assert!(
706 matches!(
707 &err,
708 LogicalSchemaError::FixedBinaryInvalidWidthInSchema {
709 column,
710 byte_width
711 } if column == "bad_fixed" && *byte_width == width
712 ),
713 "unexpected error: {err:?}"
714 );
715 }
716 }
717
718 #[test]
719 fn logical_schema_rejects_int96() {
720 let logical = LogicalSchema::new(vec![LogicalField {
721 name: "legacy_ts".to_string(),
722 data_type: LogicalDataType::Int96,
723 nullable: false,
724 }])
725 .expect("valid schema structure");
726
727 let err = logical.to_arrow_schema().unwrap_err();
728 assert!(
729 matches!(
730 &err,
731 SchemaConvertError::Int96Unsupported { column } if column == "legacy_ts"
732 ),
733 "unexpected error: {err:?}"
734 );
735 }
736
737 #[test]
738 fn logical_schema_map_entries_field_is_non_nullable() {
739 let logical = LogicalSchema::new(vec![LogicalField {
740 name: "attrs".to_string(),
741 data_type: LogicalDataType::Map {
742 key: Box::new(LogicalField {
743 name: "key".to_string(),
744 data_type: LogicalDataType::Utf8,
745 nullable: false,
746 }),
747 value: Some(Box::new(LogicalField {
748 name: "value".to_string(),
749 data_type: LogicalDataType::Int64,
750 nullable: true,
751 })),
752 keys_sorted: false,
753 },
754 nullable: true,
755 }])
756 .expect("valid schema");
757
758 let schema = logical.to_arrow_schema().expect("arrow schema conversion");
759 let field = schema.field(0);
760 let DataType::Map(entries_field, _) = field.data_type() else {
761 panic!("expected map type, got {:?}", field.data_type());
762 };
763 assert!(
764 !entries_field.is_nullable(),
765 "map entries field should be non-nullable"
766 );
767 }
768
769 #[test]
770 fn logical_schema_map_value_none_maps_to_null_field() {
771 let logical = LogicalSchema::new(vec![LogicalField {
772 name: "attrs".to_string(),
773 data_type: LogicalDataType::Map {
774 key: Box::new(LogicalField {
775 name: "key".to_string(),
776 data_type: LogicalDataType::Utf8,
777 nullable: false,
778 }),
779 value: None,
780 keys_sorted: false,
781 },
782 nullable: false,
783 }])
784 .expect("valid schema");
785
786 let schema = logical.to_arrow_schema().expect("arrow schema conversion");
787 let field = schema.field(0);
788 let DataType::Map(entries_field, _) = field.data_type() else {
789 panic!("expected map type, got {:?}", field.data_type());
790 };
791 let DataType::Struct(fields) = entries_field.data_type() else {
792 panic!(
793 "expected entries struct, got {:?}",
794 entries_field.data_type()
795 );
796 };
797 let value_field = fields
798 .iter()
799 .find(|f| f.name() == "value")
800 .expect("value field");
801 assert!(
802 matches!(value_field.data_type(), DataType::Null) && value_field.is_nullable(),
803 "value field should be Null and nullable"
804 );
805 }
806
807 #[test]
808 fn logical_schema_rejects_empty_struct_field_name() {
809 let err = LogicalSchema::new(vec![LogicalField {
810 name: "root".to_string(),
811 data_type: LogicalDataType::Struct {
812 fields: vec![LogicalField {
813 name: "".to_string(),
814 data_type: LogicalDataType::Int32,
815 nullable: false,
816 }],
817 },
818 nullable: false,
819 }])
820 .expect_err("expected invalid schema");
821
822 assert!(
823 matches!(
824 &err,
825 LogicalSchemaError::StructFieldNameEmpty { column_path, field }
826 if column_path == "root" && field.is_empty()
827 ),
828 "unexpected error: {err:?}"
829 );
830 }
831
832 #[test]
833 fn logical_schema_rejects_other_type() {
834 let logical = LogicalSchema::new(vec![LogicalField {
835 name: "opaque".to_string(),
836 data_type: LogicalDataType::Other("parquet::Map".to_string()),
837 nullable: true,
838 }])
839 .expect("valid schema structure");
840
841 let err = logical.to_arrow_schema().unwrap_err();
842 assert!(
843 matches!(
844 &err,
845 SchemaConvertError::OtherTypeUnsupported { column, name }
846 if column == "opaque" && name == "parquet::Map"
847 ),
848 "unexpected error: {err:?}"
849 );
850 }
851
852 #[test]
853 fn logical_schema_timestamp_without_timezone() {
854 let logical = LogicalSchema::new(vec![LogicalField {
855 name: "ts".to_string(),
856 data_type: LogicalDataType::Timestamp {
857 unit: LogicalTimestampUnit::Millis,
858 timezone: None,
859 },
860 nullable: false,
861 }])
862 .expect("valid schema structure");
863
864 let schema = logical.to_arrow_schema().expect("arrow schema conversion");
865 let expected = Schema::new(vec![Field::new(
866 "ts",
867 DataType::Timestamp(TimeUnit::Millisecond, None),
868 false,
869 )]);
870 assert_eq!(schema, expected);
871 }
872
873 #[test]
874 fn logical_schema_decimal_conversion_bounds() {
875 let valid_128 = LogicalSchema::new(vec![LogicalField {
876 name: "dec128".to_string(),
877 data_type: LogicalDataType::Decimal {
878 precision: 38,
879 scale: 10,
880 },
881 nullable: false,
882 }])
883 .expect("valid schema structure");
884 let schema = valid_128
885 .to_arrow_schema()
886 .expect("arrow schema conversion");
887 assert_eq!(
888 schema,
889 Schema::new(vec![Field::new(
890 "dec128",
891 DataType::Decimal128(38, 10),
892 false
893 )])
894 );
895
896 let valid_256 = LogicalSchema::new(vec![LogicalField {
897 name: "dec256".to_string(),
898 data_type: LogicalDataType::Decimal {
899 precision: 76,
900 scale: 5,
901 },
902 nullable: false,
903 }])
904 .expect("valid schema structure");
905 let schema = valid_256
906 .to_arrow_schema()
907 .expect("arrow schema conversion");
908 assert_eq!(
909 schema,
910 Schema::new(vec![Field::new(
911 "dec256",
912 DataType::Decimal256(76, 5),
913 false
914 )])
915 );
916
917 let invalid = LogicalSchema::new(vec![LogicalField {
918 name: "dec_too_large".to_string(),
919 data_type: LogicalDataType::Decimal {
920 precision: 77,
921 scale: 0,
922 },
923 nullable: false,
924 }])
925 .expect("valid schema structure");
926 let err = invalid.to_arrow_schema().unwrap_err();
927 assert!(
928 matches!(
929 &err,
930 SchemaConvertError::DecimalInvalid { column, precision, scale, .. }
931 if column == "dec_too_large" && *precision == 77 && *scale == 0
932 ),
933 "unexpected error: {err:?}"
934 );
935 }
936
937 #[test]
938 fn logical_schema_decimal_validation_errors() {
939 let cases = vec![
940 ("dec_precision_zero", 0, 0, "precision must be > 0"),
941 ("dec_scale_negative", 10, -1, "scale must be >= 0"),
942 ("dec_scale_gt_precision", 4, 5, "scale must be <= precision"),
943 ];
944
945 for (name, precision, scale, details_substr) in cases {
946 let logical = LogicalSchema::new(vec![LogicalField {
947 name: name.to_string(),
948 data_type: LogicalDataType::Decimal { precision, scale },
949 nullable: false,
950 }])
951 .expect("valid schema structure");
952
953 let err = logical.to_arrow_schema().unwrap_err();
954 assert!(
955 matches!(
956 &err,
957 SchemaConvertError::DecimalInvalid { column, precision: p, scale: s, details }
958 if column == name && *p == precision && *s == scale && details.contains(details_substr)
959 ),
960 "unexpected error: {err:?}"
961 );
962 }
963 }
964
965 #[test]
966 fn logical_schema_fixed_binary_json_roundtrip() {
967 let logical = LogicalSchema::new(vec![LogicalField {
968 name: "fixed".to_string(),
969 data_type: LogicalDataType::FixedBinary { byte_width: 8 },
970 nullable: false,
971 }])
972 .expect("valid schema structure");
973
974 let json = serde_json::to_string(&logical).unwrap();
975 let back: LogicalSchema = serde_json::from_str(&json).unwrap();
976 assert_eq!(back, logical);
977 }
978
979 #[test]
980 fn logical_schema_decimal_json_roundtrip() {
981 let logical = LogicalSchema::new(vec![LogicalField {
982 name: "amount".to_string(),
983 data_type: LogicalDataType::Decimal {
984 precision: 18,
985 scale: 4,
986 },
987 nullable: true,
988 }])
989 .expect("valid schema structure");
990
991 let json = serde_json::to_string(&logical).unwrap();
992 let back: LogicalSchema = serde_json::from_str(&json).unwrap();
993 assert_eq!(back, logical);
994 }
995
996 #[test]
997 fn logical_schema_uint64_roundtrips_json_and_maps_exactly_to_arrow() {
998 let schema = LogicalSchema::new(vec![LogicalField {
999 name: "offset".to_string(),
1000 data_type: LogicalDataType::UInt64,
1001 nullable: false,
1002 }])
1003 .unwrap();
1004
1005 let json = serde_json::to_string(&schema).unwrap();
1006 let restored: LogicalSchema = serde_json::from_str(&json).unwrap();
1007 assert_eq!(restored, schema);
1008 assert_eq!(
1009 restored.to_arrow_schema().unwrap().field(0).data_type(),
1010 &DataType::UInt64
1011 );
1012 }
1013}