diff --git a/native/spark-expr/src/conversion_funcs/cast.rs b/native/spark-expr/src/conversion_funcs/cast.rs index 7a9b93ff16..45c3ea678c 100644 --- a/native/spark-expr/src/conversion_funcs/cast.rs +++ b/native/spark-expr/src/conversion_funcs/cast.rs @@ -45,8 +45,7 @@ use arrow::array::builder::{GenericStringBuilder, StringBuilder}; use arrow::array::{ new_null_array, BinaryBuilder, GenericByteArray, ListArray, MapArray, StringArray, StructArray, }; -use arrow::datatypes::{DataType, Schema}; -use arrow::datatypes::{Field, Fields, GenericBinaryType}; +use arrow::datatypes::{DataType, GenericBinaryType, Schema}; use arrow::error::ArrowError; use arrow::{ array::{ @@ -490,8 +489,21 @@ fn cast_struct_to_struct( } } -/// Cast between map types, handling field name differences between Parquet ("key_value") -/// and Spark ("entries") while preserving the map's structure. +/// Cast between map types, including the relabel case where the child types match and only +/// field names, nullability or metadata differ. +/// +/// - Rename-only (unchanged key/value types and sort order): delegate to arrow's `cast`, which +/// relabels to the target fields and preserves their metadata with no value transformation. +/// - Otherwise (a child type or the sort flag differs): recurse with Comet's `cast_array` for a +/// changed child and hand-build the result with the target sort flag. `try_new` is used so a +/// malformed target returns `Err` rather than panicking. +/// +/// Either way the result `data_type()` equals `to_type`. +/// +/// The target sort flag is copied, not re-derived. `serde.rs` builds every map type with +/// `sorted = false` and the planner propagates that unchanged, so a target asking for +/// `sorted = true` does not arise today. A producer that started emitting one would need the flag +/// recomputed here, because casting the key type can reorder keys. fn cast_map_to_map( array: &ArrayRef, from_type: &DataType, @@ -506,75 +518,84 @@ fn cast_map_to_map( match (from_type, to_type) { ( DataType::Map(from_entries_field, from_sorted), - DataType::Map(to_entries_field, _to_sorted), + DataType::Map(to_entries_field, to_sorted), ) => { - // Get the struct types for entries - let from_struct_type = from_entries_field.data_type(); - let to_struct_type = to_entries_field.data_type(); - - match (from_struct_type, to_struct_type) { - (DataType::Struct(from_fields), DataType::Struct(to_fields)) => { - // Get the key and value types - let from_key_type = from_fields[0].data_type(); - let from_value_type = from_fields[1].data_type(); - let to_key_type = to_fields[0].data_type(); - let to_value_type = to_fields[1].data_type(); - - // Cast keys if needed - let keys = map_array.keys(); - let cast_keys = if from_key_type != to_key_type { - cast_array(Arc::clone(keys), to_key_type, cast_options)? - } else { - Arc::clone(keys) - }; - - // Cast values if needed - let values = map_array.values(); - let cast_values = if from_value_type != to_value_type { - cast_array(Arc::clone(values), to_value_type, cast_options)? - } else { - Arc::clone(values) - }; - - // Build the new entries struct with the target field names - let new_key_field = Arc::new(Field::new( - to_fields[0].name(), - to_key_type.clone(), - to_fields[0].is_nullable(), - )); - let new_value_field = Arc::new(Field::new( - to_fields[1].name(), - to_value_type.clone(), - to_fields[1].is_nullable(), - )); - - let struct_fields = Fields::from(vec![new_key_field, new_value_field]); - let entries_struct = - StructArray::new(struct_fields, vec![cast_keys, cast_values], None); - - // Create the new map field with the target name - let new_entries_field = Arc::new(Field::new( - to_entries_field.name(), - DataType::Struct(entries_struct.fields().clone()), - to_entries_field.is_nullable(), - )); - - // Build the new MapArray - let new_map = MapArray::new( - new_entries_field, - map_array.offsets().clone(), - entries_struct, - map_array.nulls().cloned(), - *from_sorted, - ); - - Ok(Arc::new(new_map)) - } - _ => Err(DataFusionError::Internal(format!( - "Map entries must be structs, got {:?} and {:?}", - from_struct_type, to_struct_type - ))), + let (from_fields, to_fields) = + match (from_entries_field.data_type(), to_entries_field.data_type()) { + (DataType::Struct(f), DataType::Struct(t)) => (f, t), + (from_struct_type, to_struct_type) => { + return Err(DataFusionError::Internal(format!( + "Map entries must be structs, got {from_struct_type:?} and \ + {to_struct_type:?}" + ))) + } + }; + // Both field lists are indexed below. This guard is load-bearing for a target with 0 + // or 1 fields, which would otherwise panic on that indexing. A target with 3 or more is + // already rejected without it, by `MapArray::try_new`'s entries-type check on the + // delegated path and by `StructArray::try_new` on the hand-built one, so there the guard + // only makes the error clearer. + if to_fields.len() != 2 { + return Err(DataFusionError::Internal(format!( + "Map entries struct in the cast target must have exactly 2 fields \ + (key, value), got {}", + to_fields.len() + ))); + } + // No matching guard on `from_fields`: a `MapArray` from arrow's safe constructors + // cannot have any other entries field count. `MapArray::try_new` rejects a declared + // entries field whose type differs from the entries array, `try_new_from_array_data` + // requires a two-field entries struct, and `ArrayData::validate_child_data` requires + // matching child types. + let key_type_unchanged = from_fields[0].data_type() == to_fields[0].data_type(); + let value_type_unchanged = from_fields[1].data_type() == to_fields[1].data_type(); + + // Rename-only path: the key and value types and the sort order are unchanged, so only + // the field labels, nullability and metadata differ. Delegate to arrow's cast, whose + // map arm requires matching sort flags and relabels to the target fields, preserving + // their metadata and values. The hand-built path below produces the same array for + // this case, so this reuses arrow's kernel rather than behaving differently. + // `test_cast_map_to_map_both_paths_agree` pins that equivalence. + if key_type_unchanged && value_type_unchanged && from_sorted == to_sorted { + // The eval mode cannot matter here. Both child types are unchanged, so arrow casts + // each child with `from_type == to_type` and returns it untouched without ever + // reading `safe`. Use the shared static rather than threading the mode through, so + // this does not read as if the mode changed the result. + return Ok(cast_with_options(array, to_type, &CAST_OPTIONS)?); } + + // Otherwise a child type or the sort flag differs. Recurse with Comet's Spark-compatible + // casts for the changed children and hand-build the result carrying the target sort flag. + // `try_new` reports a malformed target as `Err` rather than panicking. + let keys = map_array.keys(); + let cast_keys = if key_type_unchanged { + Arc::clone(keys) + } else { + cast_array(Arc::clone(keys), to_fields[0].data_type(), cast_options)? + }; + let values = map_array.values(); + let cast_values = if value_type_unchanged { + Arc::clone(values) + } else { + cast_array(Arc::clone(values), to_fields[1].data_type(), cast_options)? + }; + + // `None` rather than the source entries null buffer, which is always absent. + // `MapArray::try_new` rejects entries carrying any null, `StructArray::try_new` + // discards an all-valid null buffer, and the `ArrayData` route normalizes one to + // `None` in `ArrayDataBuilder::build` before validation is even reached. So + // `entries().nulls()` is `None` however the `MapArray` was built. Reading it back + // would suggest a null buffer can survive here when none can exist. + let entries_struct = + StructArray::try_new(to_fields.clone(), vec![cast_keys, cast_values], None)?; + let new_map = MapArray::try_new( + Arc::clone(to_entries_field), + map_array.offsets().clone(), + entries_struct, + map_array.nulls().cloned(), + *to_sorted, + )?; + Ok(Arc::new(new_map)) } _ => unreachable!("cast_map_to_map called with non-Map types"), } @@ -1237,4 +1258,457 @@ mod tests { assert_eq!(3, values.null_count()); assert!(values.iter().all(|value| value.is_none())); } + fn legacy_opts() -> SparkCastOptions { + SparkCastOptions::new(EvalMode::Legacy, "UTC", false) + } + + /// Build a `Map` MapArray (Parquet-style "key_value" field names). + fn build_str_i32_map( + keys: Vec<&str>, + values: Vec>, + offsets: Vec, + map_nulls: Option, + sorted: bool, + ) -> MapArray { + use arrow::array::{Int32Array, StringArray}; + let key_field = Arc::new(Field::new("key_value_key", DataType::Utf8, false)); + let value_field = Arc::new(Field::new("key_value_value", DataType::Int32, true)); + let entries_fields = Fields::from(vec![key_field, value_field]); + let ks = Arc::new(StringArray::from(keys)) as ArrayRef; + let vs = Arc::new(Int32Array::from(values)) as ArrayRef; + // Entries nulls are always None: MapArray::new rejects entries carrying any null. + let entries_struct = StructArray::new(entries_fields, vec![ks, vs], None); + let entries_field = Arc::new(Field::new( + "key_value", + DataType::Struct(entries_struct.fields().clone()), + false, + )); + MapArray::new( + entries_field, + OffsetBuffer::::new(offsets.into()), + entries_struct, + map_nulls, + sorted, + ) + } + + /// Build a target `Map` type ("entries"/"key"/"value" Spark-style names). + fn build_to_map_type(val_type: DataType, val_nullable: bool, sorted: bool) -> DataType { + let to_key = Arc::new(Field::new("key", DataType::Utf8, false)); + let to_val = Arc::new(Field::new("value", val_type, val_nullable)); + let entries = Arc::new(Field::new( + "entries", + DataType::Struct(Fields::from(vec![to_key, to_val])), + false, + )); + DataType::Map(entries, sorted) + } + + /// Assert which branch of `cast_map_to_map` a (from, to) type pair selects, by checking the + /// three inputs the branch condition is built from. + fn assert_map_child_types_and_sort( + from_type: &DataType, + to_type: &DataType, + expect_key_unchanged: bool, + expect_value_unchanged: bool, + expect_same_sort: bool, + ) { + let children = |t: &DataType| match t { + DataType::Map(entries, sorted) => match entries.data_type() { + DataType::Struct(f) => { + assert_eq!(f.len(), 2, "map entries must be (key, value)"); + (f[0].data_type().clone(), f[1].data_type().clone(), *sorted) + } + other => panic!("map entries must be a struct, got {other:?}"), + }, + other => panic!("expected a Map type, got {other:?}"), + }; + let (from_key, from_val, from_sorted) = children(from_type); + let (to_key, to_val, to_sorted) = children(to_type); + assert_eq!( + from_key == to_key, + expect_key_unchanged, + "key type unchanged: {from_key:?} vs {to_key:?}" + ); + assert_eq!( + from_val == to_val, + expect_value_unchanged, + "value type unchanged: {from_val:?} vs {to_val:?}" + ); + assert_eq!( + from_sorted == to_sorted, + expect_same_sort, + "sort flag unchanged: {from_sorted} vs {to_sorted}" + ); + } + + fn sorted_src(from_sorted: bool) -> ArrayRef { + Arc::new(build_str_i32_map( + vec!["a", "b", "c"], + vec![Some(1), Some(2), Some(3)], + vec![0, 3], + None, + from_sorted, + )) as ArrayRef + } + + #[test] + fn test_cast_map_to_map_sorted_true_to_false() { + // Downgrade a sorted map to unsorted: allowed, result carries the target (false) flag. + let to_type = build_to_map_type(DataType::Int32, true, false); + let casted = cast_array(sorted_src(true), &to_type, &legacy_opts()).unwrap(); + assert_eq!(casted.data_type(), &to_type); + match casted.data_type() { + DataType::Map(_, is_sorted) => assert!(!*is_sorted), + _ => panic!("Expected Map DataType"), + } + } + + #[test] + fn test_cast_map_to_map_preserves_metadata_and_child_type_casts() { + use arrow::array::{Int32Array, Int64Array, StringArray}; + use std::collections::HashMap; + + let key_field = Arc::new(Field::new("key_value_key", DataType::Utf8, false)); + let value_field = Arc::new(Field::new("key_value_value", DataType::Int32, true)); + let entries_fields = Fields::from(vec![key_field, value_field]); + + let keys = Arc::new(StringArray::from(vec!["k1", "k2"])); + let values = Arc::new(Int32Array::from(vec![10, 20])); + let entries_struct = StructArray::new(entries_fields, vec![keys, values], None); + + let from_entries_field = Arc::new(Field::new( + "key_value", + DataType::Struct(entries_struct.fields().clone()), + false, + )); + let map_array = Arc::new(MapArray::new( + from_entries_field, + OffsetBuffer::::new(vec![0, 2].into()), + entries_struct, + None, + false, + )) as ArrayRef; + + // `serde.rs` attaches the Parquet field id to the map key and value fields, so a rebuild + // with `Field::new` drops it. Use the real metadata key (`PARQUET_FIELD_ID_META_KEY` in + // `parquet::arrow`, whose value is "PARQUET:field_id") so this pins that consequence and + // not just metadata in general. + let field_id_key = "PARQUET:field_id"; + let to_key_field = Arc::new( + Field::new("key", DataType::Utf8, false) + .with_metadata(HashMap::from([(field_id_key.to_string(), "7".to_string())])), + ); + // The source value field is nullable and the target's is not, so the result type carries a + // nullability delta as well as a type change. + let to_value_field = Arc::new( + Field::new("value", DataType::Int64, false) + .with_metadata(HashMap::from([(field_id_key.to_string(), "8".to_string())])), + ); + // Metadata on the entries field itself, not only on the key field. This is the level the + // production change fixed by reusing `to_entries_field` rather than rebuilding it with + // `Field::new`, and the value type change below routes this through the hand-built path. + let mut entries_meta = HashMap::new(); + entries_meta.insert("tag".to_string(), "map_entries_meta".to_string()); + let to_entries_field = Arc::new( + Field::new( + "entries", + DataType::Struct(Fields::from(vec![to_key_field, to_value_field])), + false, + ) + .with_metadata(entries_meta), + ); + let to_type = DataType::Map(to_entries_field, false); + + let casted = cast_array( + map_array, + &to_type, + &SparkCastOptions::new(EvalMode::Legacy, "UTC", false), + ) + .unwrap(); + + let casted_map = casted.as_any().downcast_ref::().unwrap(); + // Result type is exactly the requested target type (incl. field metadata). + assert_eq!(casted_map.data_type(), &to_type); + // Assert the Parquet field ids survive on both the key and the value field + assert_eq!( + casted_map.entries().fields()[0] + .metadata() + .get(field_id_key), + Some(&"7".to_string()) + ); + assert_eq!( + casted_map.entries().fields()[1] + .metadata() + .get(field_id_key), + Some(&"8".to_string()) + ); + assert!(!casted_map.entries().fields()[1].is_nullable()); + // Assert entries field metadata is preserved, which is what reusing `to_entries_field` fixed + match casted_map.data_type() { + DataType::Map(entries_field, _) => assert_eq!( + entries_field.metadata().get("tag"), + Some(&"map_entries_meta".to_string()) + ), + other => panic!("expected a Map type, got {other:?}"), + } + + // Assert child values were cast from Int32 to Int64 + let casted_values = casted_map + .values() + .as_any() + .downcast_ref::() + .unwrap(); + assert_eq!(casted_values.value(0), 10i64); + assert_eq!(casted_values.value(1), 20i64); + } + + #[test] + fn test_cast_map_to_map_rename_only_preserves_values_offsets_and_nulls() { + use arrow::array::{Int32Array, StringArray}; + use arrow::buffer::NullBuffer; + use std::collections::HashMap; + + // Key/value types unchanged and sort order unchanged -> the rename-only path (arrow + // cast) is used. Three rows including a null row so offsets and map nulls are non-trivial. + let map_nulls = NullBuffer::from(vec![true, false, true]); + let src = build_str_i32_map( + vec!["a", "b", "c"], + vec![Some(1), Some(2), Some(3)], + vec![0, 2, 2, 3], + Some(map_nulls.clone()), + false, + ); + let src_offsets: Vec = src.offsets().as_ref().to_vec(); + let map_array = Arc::new(src) as ArrayRef; + + // Complete target schema: renamed entries/key/value fields, outer + key metadata, same + // (unchanged) child types, same sort flag. + let mut outer_meta = HashMap::new(); + outer_meta.insert("outer".to_string(), "entries_meta".to_string()); + let mut key_meta = HashMap::new(); + key_meta.insert("k".to_string(), "kmeta".to_string()); + let to_key = Arc::new(Field::new("key", DataType::Utf8, false).with_metadata(key_meta)); + let to_val = Arc::new(Field::new("value", DataType::Int32, true)); + let to_entries = Arc::new( + Field::new( + "entries", + DataType::Struct(Fields::from(vec![to_key, to_val])), + false, + ) + .with_metadata(outer_meta), + ); + let to_type = DataType::Map(Arc::clone(&to_entries), false); + + let casted = cast_array(map_array, &to_type, &legacy_opts()).unwrap(); + let casted_map = casted.as_any().downcast_ref::().unwrap(); + + // The complete target schema is reproduced exactly (field names, metadata, nullability, sort). + assert_eq!(casted_map.data_type(), &to_type); + // Map-level nulls and offsets are unchanged by the relabel. + assert_eq!(casted_map.nulls(), Some(&map_nulls)); + assert!(casted_map.is_null(1)); + assert_eq!(casted_map.offsets().as_ref(), src_offsets.as_slice()); + // Keys and values are unchanged by the relabel. + let keys = casted_map + .keys() + .as_any() + .downcast_ref::() + .unwrap(); + let vals = casted_map + .values() + .as_any() + .downcast_ref::() + .unwrap(); + assert_eq!( + (0..3).map(|i| keys.value(i)).collect::>(), + vec!["a", "b", "c"] + ); + assert_eq!(vals.values(), &[1, 2, 3]); + } + + #[test] + fn test_cast_map_to_map_both_paths_agree() { + use arrow::array::{Int32Array, StringArray}; + // Two independent implementations reach the same target type, so pin that they agree. + // The only difference between the two inputs is the source `sorted` flag, which is not + // part of the output type: equal flags select the arrow delegation, differing flags + // select the hand-built path. Everything else (values, offsets, map-level nulls) matches, + // so the two results must be identical. + let rows = |sorted: bool| { + Arc::new(build_str_i32_map( + vec!["a", "b", "c", "d"], + vec![Some(1), None, Some(3), Some(4)], + vec![0, 1, 3, 3, 4], + Some(arrow::buffer::NullBuffer::from(vec![ + true, true, false, true, + ])), + sorted, + )) as ArrayRef + }; + // Slice off the first row so both paths see a non-zero offset window and a null row. + let unsorted_src = rows(false).slice(1, 3); + let sorted_src = rows(true).slice(1, 3); + let to_type = build_to_map_type(DataType::Int32, true, false); + + // Fast path: child types and sort flag all unchanged. + assert_map_child_types_and_sort(unsorted_src.data_type(), &to_type, true, true, true); + // Hand-built path: child types unchanged but the sort flag differs (true -> false). + assert_map_child_types_and_sort(sorted_src.data_type(), &to_type, true, true, false); + + let fast = cast_array(unsorted_src, &to_type, &legacy_opts()).unwrap(); + let hand_built = cast_array(sorted_src, &to_type, &legacy_opts()).unwrap(); + + assert_eq!(fast.data_type(), &to_type); + assert_eq!(hand_built.data_type(), &to_type); + assert_eq!(fast.to_data(), hand_built.to_data()); + + // Assert the shared result is actually right, so agreement on a wrong value cannot pass. + let m = fast.as_any().downcast_ref::().unwrap(); + assert_eq!(m.len(), 3); + assert!(m.is_valid(0) && !m.is_valid(1) && m.is_valid(2)); + let keys = m.keys().as_any().downcast_ref::().unwrap(); + let vals = m.values().as_any().downcast_ref::().unwrap(); + let o = m.offsets(); + let (start, end) = (o[0] as usize, o[3] as usize); + let got_keys: Vec<&str> = (start..end).map(|i| keys.value(i)).collect(); + let got_vals: Vec> = (start..end) + .map(|i| (!vals.is_null(i)).then(|| vals.value(i))) + .collect(); + assert_eq!(got_keys, vec!["b", "c", "d"]); + assert_eq!(got_vals, vec![None, Some(3), Some(4)]); + } + + #[test] + fn test_cast_map_to_map_casts_key_and_value() { + use arrow::array::{Int32Array, Int64Array}; + use std::collections::HashMap; + // Source Map -> target Map: both key and value are cast. + let key_field = Arc::new(Field::new("key_value_key", DataType::Int32, false)); + let value_field = Arc::new(Field::new("key_value_value", DataType::Int32, true)); + let entries_fields = Fields::from(vec![key_field, value_field]); + let ks = Arc::new(Int32Array::from(vec![1, 2])) as ArrayRef; + let vs = Arc::new(Int32Array::from(vec![10, 20])) as ArrayRef; + let entries_struct = StructArray::new(entries_fields, vec![ks, vs], None); + let entries_field = Arc::new(Field::new( + "key_value", + DataType::Struct(entries_struct.fields().clone()), + false, + )); + let src = Arc::new(MapArray::new( + entries_field, + OffsetBuffer::::new(vec![0, 2].into()), + entries_struct, + None, + false, + )) as ArrayRef; + + // The target key carries metadata so this pins the metadata as well as the cast. Without + // it a plain type change is reproduced exactly by rebuilding the fields with + // `Field::new`, and the test would pass against the unfixed function. + let to_key = Arc::new(Field::new("key", DataType::Int64, false).with_metadata( + HashMap::from([("PARQUET:field_id".to_string(), "7".to_string())]), + )); + let to_val = Arc::new(Field::new("value", DataType::Int64, true)); + let to_entries = Arc::new(Field::new( + "entries", + DataType::Struct(Fields::from(vec![to_key, to_val])), + false, + )); + let to_type = DataType::Map(Arc::clone(&to_entries), false); + + let casted = cast_array(src, &to_type, &legacy_opts()).unwrap(); + assert_eq!(casted.data_type(), &to_type); + let m = casted.as_any().downcast_ref::().unwrap(); + let keys = m.keys().as_any().downcast_ref::().unwrap(); + let vals = m.values().as_any().downcast_ref::().unwrap(); + assert_eq!(keys.values(), &[1i64, 2]); + assert_eq!(vals.values(), &[10i64, 20]); + } + + #[test] + fn test_cast_map_to_map_malformed_target_returns_err_without_panic() { + // Source value has a NULL; the target changes the value type (Int32 -> Int64) AND declares + // it NON-nullable. The type change routes through the hand-built child-cast path, and the + // resulting null in a non-nullable field makes `StructArray::try_new` return Err (no panic). + let src = Arc::new(build_str_i32_map( + vec!["a", "b"], + vec![Some(1), None], + vec![0, 2], + None, + false, + )) as ArrayRef; + let to_type = build_to_map_type(DataType::Int64, false, false); + let err = cast_array(src, &to_type, &legacy_opts()).unwrap_err(); + assert!( + err.to_string() + .contains(r#"Found unmasked nulls for non-nullable StructArray field "value""#), + "a null in a non-nullable target child must fail in StructArray::try_new, got: {err}" + ); + } + + fn one_row_src() -> ArrayRef { + Arc::new(build_str_i32_map( + vec!["a"], + vec![Some(1)], + vec![0, 1], + None, + false, + )) as ArrayRef + } + + fn map_target_with_entry_fields(fields: Vec>) -> DataType { + let entries = Arc::new(Field::new( + "entries", + DataType::Struct(Fields::from(fields)), + false, + )); + DataType::Map(entries, false) + } + + fn entry_field(n: &str, t: DataType) -> Arc { + Arc::new(Field::new(n, t, false)) + } + + // A target whose entries struct is not exactly (key, value) must hit the field-count guard. The + // 0- and 1-field cases would otherwise panic on the `[0]`/`[1]` indexing. A 3-field target is + // rejected downstream even without the guard, so these assert the guard's own message rather + // than bare `is_err`, which an unrelated failure would satisfy just as well. + // Split per field-count so a panic in one case cannot hide the others. + #[test] + fn test_cast_map_to_map_zero_entry_fields_errs() { + let to_type = map_target_with_entry_fields(vec![]); + let err = cast_array(one_row_src(), &to_type, &legacy_opts()).unwrap_err(); + assert!( + err.to_string() + .contains("Map entries struct in the cast target must have exactly 2 fields"), + "0 entry fields must hit the target field-count guard, got: {err}" + ); + } + + #[test] + fn test_cast_map_to_map_one_entry_field_errs() { + let to_type = map_target_with_entry_fields(vec![entry_field("key", DataType::Utf8)]); + let err = cast_array(one_row_src(), &to_type, &legacy_opts()).unwrap_err(); + assert!( + err.to_string() + .contains("Map entries struct in the cast target must have exactly 2 fields"), + "1 entry field must hit the target field-count guard, got: {err}" + ); + } + + #[test] + fn test_cast_map_to_map_three_entry_fields_errs() { + let to_type = map_target_with_entry_fields(vec![ + entry_field("key", DataType::Utf8), + entry_field("value", DataType::Int32), + entry_field("extra", DataType::Int32), + ]); + let err = cast_array(one_row_src(), &to_type, &legacy_opts()).unwrap_err(); + assert!( + err.to_string() + .contains("Map entries struct in the cast target must have exactly 2 fields"), + "3 entry fields must hit the target field-count guard, got: {err}" + ); + } }