| // 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 arrow::array::{ | |
| new_null_array, Array, ArrayRef, BooleanBufferBuilder, MapArray, NullBufferBuilder, Scalar, | |
| UInt32Array, | |
| }; | |
| use arrow::buffer::BooleanBuffer; | |
| use arrow::compute::kernels::cmp::eq; | |
| use arrow::compute::take; | |
| use arrow::datatypes::{DataType, FieldRef}; | |
| use datafusion::common::utils::take_function_args; | |
| use datafusion::common::{exec_err, Result as DataFusionResult}; | |
| use datafusion::logical_expr::{ | |
| ColumnarValue, ScalarFunctionArgs, ScalarUDFImpl, Signature, Volatility, | |
| }; | |
| use std::sync::Arc; | |
| /// Spark's map lookup: `GetMapValue` (`m[k]`) and `element_at(<map>, k)`. | |
| /// | |
| /// Overrides DataFusion's `map_extract` under the same name, and differs from it in two ways: | |
| /// | |
| /// - it returns the matched **value** rather than a one-element list, so the planner does not | |
| /// have to unwrap the list with a second `ListExtract` pass (see `planner.rs`); | |
| /// - the lookup is vectorized. DataFusion's `general_map_extract_inner` re-slices the query key | |
| /// and every candidate key into a fresh `ArrayRef` per comparison and compares them through | |
| /// `dyn Array` equality, which made a constant-key lookup roughly 35x more expensive than any | |
| /// other Comet map kernel and slower than Spark itself | |
| /// ([#5795](https://github.com/apache/datafusion-comet/issues/5795)). Here a single Arrow | |
| /// `eq` covers the whole batch of entries at once, the per-row work is a bit scan over the | |
| /// resulting mask, and the values are gathered with one `take`. | |
| /// | |
| /// Spark's own lookup returns the first entry whose key compares equal, so the mask scan stops at | |
| /// the first match too. A missing key, a `NULL` map row, and a `NULL` lookup key all produce | |
| /// `NULL`, matching `GetMapValueUtil.getValueEval` and `ElementAt`'s map overload. | |
| /// | |
| /// Key types whose Spark equality this cannot reproduce (floating point, non-default collations, | |
| /// complex keys) never reach here: `MapKeySupport` in `serde/maps.scala` declines them so the | |
| /// expression falls back to Spark. | |
| /// | |
| /// One capability of DataFusion's kernel is dropped deliberately: it special-cases a | |
| /// `DataType::Null` first argument in `return_type`, `coerce_types` and its inner loop and answers | |
| /// `NULL`, where this rejects it as not a map. Spark's `ExtractValue.apply` requires a `MapType` | |
| /// child and `CometElementAt.getSupportLevel` declines anything that is neither an array nor a | |
| /// map, so a `Null`-typed map argument cannot reach here from Comet. | |
| #[derive(Debug, Hash, Eq, PartialEq)] | |
| pub struct SparkMapExtract { | |
| signature: Signature, | |
| aliases: Vec<String>, | |
| } | |
| impl Default for SparkMapExtract { | |
| fn default() -> Self { | |
| Self::new() | |
| } | |
| } | |
| impl SparkMapExtract { | |
| pub fn new() -> Self { | |
| Self { | |
| // `user_defined` so `coerce_types` runs and casts the lookup key to the map's key | |
| // type; Comet's planner applies that coercion to the argument expression. | |
| signature: Signature::user_defined(Volatility::Immutable), | |
| aliases: vec!["element_at".to_string()], | |
| } | |
| } | |
| } | |
| impl ScalarUDFImpl for SparkMapExtract { | |
| fn name(&self) -> &str { | |
| "map_extract" | |
| } | |
| fn signature(&self) -> &Signature { | |
| &self.signature | |
| } | |
| /// The same alias DataFusion's `map_extract` declares. `register_udf` inserts one registry | |
| /// entry per alias, so without this the override would be partial: `map_extract` would resolve | |
| /// here while `element_at` still resolved to the list-returning DataFusion kernel. Comet emits | |
| /// only the name `map_extract` today, so this keeps the registry consistent rather than fixing | |
| /// a live bug. | |
| fn aliases(&self) -> &[String] { | |
| &self.aliases | |
| } | |
| fn return_type(&self, arg_types: &[DataType]) -> DataFusionResult<DataType> { | |
| let [map_type, _] = take_function_args(self.name(), arg_types)?; | |
| Ok(map_entry_fields(map_type)?.1.data_type().clone()) | |
| } | |
| fn coerce_types(&self, arg_types: &[DataType]) -> DataFusionResult<Vec<DataType>> { | |
| let [map_type, _] = take_function_args(self.name(), arg_types)?; | |
| Ok(vec![ | |
| map_type.clone(), | |
| map_entry_fields(map_type)?.0.data_type().clone(), | |
| ]) | |
| } | |
| fn invoke_with_args(&self, args: ScalarFunctionArgs) -> DataFusionResult<ColumnarValue> { | |
| let [map_arg, key_arg] = take_function_args(self.name(), &args.args)?; | |
| spark_map_extract(map_arg, key_arg, args.number_rows) | |
| } | |
| } | |
| /// The `(key, value)` fields of a `Map`'s entry struct. | |
| fn map_entry_fields(map_type: &DataType) -> DataFusionResult<(&FieldRef, &FieldRef)> { | |
| match map_type { | |
| DataType::Map(entries, _) => match entries.data_type() { | |
| DataType::Struct(fields) if fields.len() == 2 => Ok((&fields[0], &fields[1])), | |
| other => exec_err!("map_extract: map entries must be a two-field struct, got {other}"), | |
| }, | |
| other => exec_err!("map_extract: the first argument must be a map, got {other}"), | |
| } | |
| } | |
| /// Look up `key_arg` in each row of `map_arg`, returning the matched value or `NULL`. | |
| fn spark_map_extract( | |
| map_arg: &ColumnarValue, | |
| key_arg: &ColumnarValue, | |
| number_rows: usize, | |
| ) -> DataFusionResult<ColumnarValue> { | |
| // Expanding a scalar map builds `number_rows` copies of its entries. No Comet path reaches | |
| // that: the native `Literal` proto carries no map, so `CometLiteral` rebuilds a folded | |
| // `MapType` literal as a `CreateMap` tree that `CometCreateMap` hands to the JVM codegen | |
| // dispatcher, which yields an array; the one map-producing literal shape that stays native, | |
| // `MapFromArrays` over two empty arrays, has no entries and stops at the fast path below. | |
| // Other users of the crate get the general (if unoptimized) answer rather than an error. | |
| let map_ref: ArrayRef = match map_arg { | |
| ColumnarValue::Array(array) => Arc::clone(array), | |
| ColumnarValue::Scalar(scalar) => scalar.to_array_of_size(number_rows)?, | |
| }; | |
| let Some(map_array) = map_ref.as_any().downcast_ref::<MapArray>() else { | |
| return exec_err!( | |
| "map_extract: the first argument must be a map, got {}", | |
| map_ref.data_type() | |
| ); | |
| }; | |
| let num_rows = map_array.len(); | |
| let value_type = map_array.value_type(); | |
| validate_lookup_key(key_arg, map_array.key_type(), num_rows)?; | |
| // Arrow keeps a sliced `MapArray`'s entries child intact and slices only the offsets, so the | |
| // offsets index the *unsliced* keys/values and the visible entries are the half-open range | |
| // [entries_start, entries_end). Comparing only that window keeps a native OFFSET from paying | |
| // for the entries it skipped. | |
| let offsets = map_array.offsets(); | |
| let entries_start = offsets[0] as usize; | |
| let entries_end = offsets[num_rows] as usize; | |
| if entries_start == entries_end { | |
| // Every row is empty or NULL, so nothing can match. | |
| return Ok(ColumnarValue::Array(new_null_array(value_type, num_rows))); | |
| } | |
| let window_len = entries_end - entries_start; | |
| let keys = map_array.keys().slice(entries_start, window_len); | |
| let matched = match key_arg { | |
| ColumnarValue::Scalar(scalar) => { | |
| if scalar.is_null() { | |
| // Spark map keys are never NULL, so a NULL lookup key matches nothing. | |
| return Ok(ColumnarValue::Array(new_null_array(value_type, num_rows))); | |
| } | |
| let key = scalar.to_array_of_size(1)?; | |
| key_match_mask(&keys, &key, true)? | |
| } | |
| ColumnarValue::Array(key_array) => { | |
| // One vectorized compare needs a lookup key per *entry*, not per row, so gather each | |
| // row's key across that row's entries. Entries in a gap between two rows (offsets are | |
| // only required to be monotonic) keep index 0; the per-row scan below never reads | |
| // those positions. | |
| let mut gather = vec![0u32; window_len]; | |
| for row in 0..num_rows { | |
| let start = offsets[row] as usize - entries_start; | |
| let end = offsets[row + 1] as usize - entries_start; | |
| gather[start..end].fill(row as u32); | |
| } | |
| let per_entry_key = take(key_array, &UInt32Array::from(gather), None)?; | |
| key_match_mask(&keys, &per_entry_key, false)? | |
| } | |
| }; | |
| // Gather the first matching entry of each row. Map offsets are `i32`, so an entry index always | |
| // fits in `u32`. | |
| // | |
| // A NULL map row reads NULL whatever its entries hold. Arrow does not require a null row's | |
| // offset range to be empty, and nothing downstream of a producer re-establishes that: adding a | |
| // parent null mask over intact child buffers (as Comet's struct-field helper does) leaves any | |
| // entries the child already had in place, and the UDF execution layer does not clear them | |
| // either. Every producer traced so far -- the Parquet readers, Spark's `ArrowWriter`, and | |
| // Arrow's own `filter` / `take` / `concat` -- gives a null row an empty range, so this is a | |
| // representation the format permits rather than one known to arrive here. Guard it anyway: | |
| // Spark returns NULL for a NULL map under both ANSI modes, for `element_at` and `GetMapValue` | |
| // alike, and only `element_at` has a nullable-input guard upstream of this kernel. | |
| let map_nulls = map_array.nulls(); | |
| let mut indices = vec![0u32; num_rows]; | |
| let mut nulls = NullBufferBuilder::new(num_rows); | |
| for row in 0..num_rows { | |
| if map_nulls.is_some_and(|n| n.is_null(row)) { | |
| nulls.append(false); | |
| continue; | |
| } | |
| let start = offsets[row] as usize - entries_start; | |
| let end = offsets[row + 1] as usize - entries_start; | |
| let found = (start..end).find(|&i| matched.value(i)); | |
| if let Some(i) = found { | |
| indices[row] = (i + entries_start) as u32; | |
| } | |
| nulls.append(found.is_some()); | |
| } | |
| let indices = UInt32Array::new(indices.into(), nulls.finish()); | |
| Ok(ColumnarValue::Array(take( | |
| map_array.values(), | |
| &indices, | |
| None, | |
| )?)) | |
| } | |
| /// Reject a lookup key this kernel cannot compare against `key_type`. | |
| /// | |
| /// Both checks are properties of the call rather than of the data, so they run before the | |
| /// data-dependent early returns in [`spark_map_extract`]. Validating them later would make the | |
| /// same query succeed on a batch whose maps are all empty or all NULL and fail on the next batch. | |
| fn validate_lookup_key( | |
| key_arg: &ColumnarValue, | |
| key_type: &DataType, | |
| num_rows: usize, | |
| ) -> DataFusionResult<()> { | |
| let lookup_type = match key_arg { | |
| ColumnarValue::Array(key_array) => { | |
| if key_array.len() != num_rows { | |
| return exec_err!( | |
| "map_extract: expected {num_rows} lookup keys, got {}", | |
| key_array.len() | |
| ); | |
| } | |
| key_array.data_type().clone() | |
| } | |
| ColumnarValue::Scalar(scalar) => scalar.data_type(), | |
| }; | |
| // The planner casts the lookup key to the map's declared key type, so a mismatch here means | |
| // the runtime encoding is not the declared one (a dictionary-encoded key column, say). Reject | |
| // it rather than comparing incomparable encodings and reporting every row as a miss. | |
| if &lookup_type != key_type { | |
| return exec_err!( | |
| "map_extract: lookup key type {lookup_type} does not match the map key type {key_type}" | |
| ); | |
| } | |
| Ok(()) | |
| } | |
| /// A bit per map entry: set where the stored key equals the lookup key. `lookup` is either a | |
| /// length-1 array broadcast over every entry (constant key) or one key per entry. | |
| fn key_match_mask( | |
| keys: &ArrayRef, | |
| lookup: &ArrayRef, | |
| lookup_is_scalar: bool, | |
| ) -> DataFusionResult<BooleanBuffer> { | |
| // `eq` rejects nested key types, so those take DataFusion's element-wise comparison instead. | |
| // `MapKeySupport` declines every complex key type, so this backstop is unreachable from Comet | |
| // and exists for other users of the crate. Decide from the type rather than from an `eq` | |
| // failure: with the lookup's length and type already checked, nesting is the only thing left | |
| // that `eq` rejects, so any other error is a real one and should surface rather than fall into | |
| // a silent per-row throughput cliff. | |
| if keys.data_type().is_nested() { | |
| return Ok(elementwise_match_mask(keys, lookup, lookup_is_scalar)); | |
| } | |
| let compared = if lookup_is_scalar { | |
| eq(keys, &Scalar::new(Arc::clone(lookup)))? | |
| } else { | |
| eq(keys, lookup)? | |
| }; | |
| // A NULL on either side compares as NULL, which is not a match. | |
| let (values, nulls) = compared.into_parts(); | |
| Ok(match nulls { | |
| Some(nulls) if nulls.null_count() > 0 => &values & nulls.inner(), | |
| _ => values, | |
| }) | |
| } | |
| fn elementwise_match_mask( | |
| keys: &ArrayRef, | |
| lookup: &ArrayRef, | |
| lookup_is_scalar: bool, | |
| ) -> BooleanBuffer { | |
| let mut builder = BooleanBufferBuilder::new(keys.len()); | |
| for i in 0..keys.len() { | |
| let lookup_row = if lookup_is_scalar { 0 } else { i }; | |
| builder.append( | |
| !lookup.is_null(lookup_row) | |
| && keys.slice(i, 1).as_ref() == lookup.slice(lookup_row, 1).as_ref(), | |
| ); | |
| } | |
| builder.finish() | |
| } | |
| #[cfg(test)] | |
| mod tests { | |
| use super::*; | |
| use arrow::array::{Int32Array, StringArray, StructArray}; | |
| use arrow::buffer::{NullBuffer, OffsetBuffer}; | |
| use arrow::datatypes::{Field, Fields}; | |
| use datafusion::common::ScalarValue; | |
| /// One row of a test map: `None` for a NULL map, otherwise its `(key, value)` entries. | |
| type MapRow<'a> = Option<Vec<(&'a str, Option<i32>)>>; | |
| /// `{"a": 1, "b": 2}`, `{}`, `{"c": 3, "a": 30}`, NULL, `{"b": NULL}` | |
| fn test_map() -> MapArray { | |
| map_from(vec![ | |
| Some(vec![("a", Some(1)), ("b", Some(2))]), | |
| Some(vec![]), | |
| Some(vec![("c", Some(3)), ("a", Some(30))]), | |
| None, | |
| Some(vec![("b", None)]), | |
| ]) | |
| } | |
| /// A `MapArray` over already-built children. The fixtures below vary only in their key/value | |
| /// types, offsets and null mask, so they all build through here. | |
| fn map_of( | |
| keys: ArrayRef, | |
| values: ArrayRef, | |
| offsets: Vec<i32>, | |
| nulls: Option<NullBuffer>, | |
| ) -> MapArray { | |
| let fields = Fields::from(vec![ | |
| Arc::new(Field::new("key", keys.data_type().clone(), false)), | |
| Arc::new(Field::new("value", values.data_type().clone(), true)), | |
| ]); | |
| let entries = StructArray::new(fields.clone(), vec![keys, values], None); | |
| let entries_field = Arc::new(Field::new("entries", DataType::Struct(fields), false)); | |
| MapArray::try_new( | |
| entries_field, | |
| OffsetBuffer::new(offsets.into()), | |
| entries, | |
| nulls, | |
| false, | |
| ) | |
| .unwrap() | |
| } | |
| fn map_from(rows: Vec<MapRow>) -> MapArray { | |
| let mut keys = Vec::new(); | |
| let mut values = Vec::new(); | |
| let mut offsets = vec![0i32]; | |
| let mut nulls = NullBufferBuilder::new(rows.len()); | |
| for row in &rows { | |
| match row { | |
| Some(entries) => { | |
| for (k, v) in entries { | |
| keys.push(*k); | |
| values.push(*v); | |
| } | |
| nulls.append(true); | |
| } | |
| None => nulls.append(false), | |
| } | |
| offsets.push(keys.len() as i32); | |
| } | |
| map_of( | |
| Arc::new(StringArray::from(keys)), | |
| Arc::new(Int32Array::from(values)), | |
| offsets, | |
| nulls.finish(), | |
| ) | |
| } | |
| fn extract(map: MapArray, key: ColumnarValue) -> Vec<Option<i32>> { | |
| let num_rows = map.len(); | |
| let result = spark_map_extract(&ColumnarValue::Array(Arc::new(map)), &key, num_rows) | |
| .unwrap() | |
| .into_array(num_rows) | |
| .unwrap(); | |
| let result = result.as_any().downcast_ref::<Int32Array>().unwrap(); | |
| (0..result.len()) | |
| .map(|i| (!result.is_null(i)).then(|| result.value(i))) | |
| .collect() | |
| } | |
| fn key(value: &str) -> ColumnarValue { | |
| ColumnarValue::Scalar(ScalarValue::Utf8(Some(value.to_string()))) | |
| } | |
| #[test] | |
| fn constant_key_hit_and_miss() { | |
| // A found key returns its value; an empty row, a NULL row, and a row without the key all | |
| // return NULL, as does a row whose stored value is NULL. | |
| assert_eq!( | |
| extract(test_map(), key("a")), | |
| vec![Some(1), None, Some(30), None, None] | |
| ); | |
| assert_eq!( | |
| extract(test_map(), key("b")), | |
| vec![Some(2), None, None, None, None] | |
| ); | |
| assert_eq!(extract(test_map(), key("zz")), vec![None; 5]); | |
| } | |
| #[test] | |
| fn duplicate_keys_return_the_first_match() { | |
| // Spark's `GetMapValueUtil` scans entries in order and stops at the first equal key, so a | |
| // map that kept duplicates (EXCEPTION dedup is a write-side check) resolves to the first. | |
| let map = map_from(vec![Some(vec![("a", Some(1)), ("a", Some(2))])]); | |
| assert_eq!(extract(map, key("a")), vec![Some(1)]); | |
| } | |
| #[test] | |
| fn null_lookup_key_matches_nothing() { | |
| assert_eq!( | |
| extract(test_map(), ColumnarValue::Scalar(ScalarValue::Utf8(None))), | |
| vec![None; 5] | |
| ); | |
| } | |
| /// A NULL row whose entries were retained rather than dropped. Every builder and kernel traced | |
| /// so far -- `map_from` here, Arrow's own builders, the Parquet readers, `filter` / `take` / | |
| /// `concat` -- gives a NULL row an empty offset range, but nothing in the format requires it, | |
| /// and adding a parent null mask over intact child buffers would not. Such a row must read | |
| /// NULL, not the value its live entry holds, so pin the contract at the kernel boundary rather | |
| /// than relying on every producer to normalize. | |
| fn null_row_with_retained_entries() -> MapArray { | |
| map_of( | |
| Arc::new(StringArray::from(vec!["a", "a", "b"])), | |
| Arc::new(Int32Array::from(vec![Some(7), Some(1), Some(2)])), | |
| // Row 0 is NULL but still spans entry 0 (`a -> 7`); row 1 is a live `{a: 1, b: 2}`. | |
| vec![0, 1, 3], | |
| Some(NullBuffer::from(vec![false, true])), | |
| ) | |
| } | |
| #[test] | |
| fn null_map_row_reads_null_even_with_live_entries() { | |
| assert_eq!( | |
| extract(null_row_with_retained_entries(), key("a")), | |
| vec![None, Some(1)] | |
| ); | |
| assert_eq!( | |
| extract(null_row_with_retained_entries(), key("b")), | |
| vec![None, Some(2)] | |
| ); | |
| } | |
| #[test] | |
| fn null_map_row_reads_null_with_a_per_row_lookup_key() { | |
| // The per-row key path gathers a key per entry, so the NULL row's entry is compared | |
| // against that row's own key. It must still be masked out. | |
| let keys: ArrayRef = Arc::new(StringArray::from(vec![Some("a"), Some("b")])); | |
| assert_eq!( | |
| extract(null_row_with_retained_entries(), ColumnarValue::Array(keys)), | |
| vec![None, Some(2)] | |
| ); | |
| } | |
| #[test] | |
| fn per_row_lookup_key() { | |
| // A different key per row, including a NULL key and a key looked up against a NULL map. | |
| let keys: ArrayRef = Arc::new(StringArray::from(vec![ | |
| Some("b"), | |
| Some("a"), | |
| Some("c"), | |
| Some("a"), | |
| None, | |
| ])); | |
| assert_eq!( | |
| extract(test_map(), ColumnarValue::Array(keys)), | |
| vec![Some(2), None, Some(3), None, None] | |
| ); | |
| } | |
| #[test] | |
| fn sliced_map_keeps_original_entry_offsets() { | |
| // Arrow slices a MapArray's offsets but not its entries, so the visible rows start part | |
| // way into the keys/values children. Reading the entries from index 0 would look up the | |
| // wrong rows. | |
| let map = test_map().slice(2, 3); | |
| assert_eq!(extract(map.clone(), key("a")), vec![Some(30), None, None]); | |
| assert_eq!(extract(map.clone(), key("c")), vec![Some(3), None, None]); | |
| assert_eq!(extract(map, key("b")), vec![None, None, None]); | |
| let keys: ArrayRef = Arc::new(StringArray::from(vec![Some("c"), Some("c"), Some("b")])); | |
| assert_eq!( | |
| extract(test_map().slice(2, 3), ColumnarValue::Array(keys)), | |
| vec![Some(3), None, None] | |
| ); | |
| } | |
| #[test] | |
| fn all_rows_empty_or_null() { | |
| // The no-entries fast path still has to produce one NULL per row, typed as the value type. | |
| let map = map_from(vec![Some(vec![]), None, Some(vec![])]); | |
| assert_eq!(extract(map, key("a")), vec![None; 3]); | |
| } | |
| #[test] | |
| fn empty_input() { | |
| let map = map_from(vec![]); | |
| assert_eq!(extract(map, key("a")), Vec::<Option<i32>>::new()); | |
| } | |
| #[test] | |
| fn non_string_keys() { | |
| // Integer keys go down the same vectorized compare; pin that the gathered value lines up | |
| // with the matching key rather than the key's position. | |
| let map = map_of( | |
| Arc::new(Int32Array::from(vec![10, 20, 30])), | |
| Arc::new(StringArray::from(vec!["x", "y", "z"])), | |
| vec![0, 2, 3], | |
| None, | |
| ); | |
| let result = spark_map_extract( | |
| &ColumnarValue::Array(Arc::new(map)), | |
| &ColumnarValue::Scalar(ScalarValue::Int32(Some(20))), | |
| 2, | |
| ) | |
| .unwrap() | |
| .into_array(2) | |
| .unwrap(); | |
| let result = result.as_any().downcast_ref::<StringArray>().unwrap(); | |
| assert_eq!(result.value(0), "y"); | |
| assert!(result.is_null(1)); | |
| } | |
| #[test] | |
| fn scalar_map_argument() { | |
| // A constant map is expanded to the batch length; the lookup still runs per row. | |
| let map = map_from(vec![Some(vec![("a", Some(7))])]); | |
| let scalar = ColumnarValue::Scalar(ScalarValue::Map(Arc::new(map))); | |
| let result = spark_map_extract(&scalar, &key("a"), 3) | |
| .unwrap() | |
| .into_array(3) | |
| .unwrap(); | |
| let result = result.as_any().downcast_ref::<Int32Array>().unwrap(); | |
| assert_eq!(result.len(), 3); | |
| assert!((0..3).all(|i| result.value(i) == 7)); | |
| } | |
| #[test] | |
| fn return_and_coerce_types() { | |
| let udf = SparkMapExtract::new(); | |
| let map_type = test_map().data_type().clone(); | |
| // The result is the map's value type, not a list of it. | |
| assert_eq!( | |
| udf.return_type(&[map_type.clone(), DataType::Utf8]) | |
| .unwrap(), | |
| DataType::Int32 | |
| ); | |
| // A wider lookup key is narrowed to the map's key type by the planner. | |
| assert_eq!( | |
| udf.coerce_types(&[map_type, DataType::LargeUtf8]).unwrap(), | |
| vec![test_map().data_type().clone(), DataType::Utf8] | |
| ); | |
| } | |
| #[test] | |
| fn non_map_first_argument_is_rejected() { | |
| let udf = SparkMapExtract::new(); | |
| assert!(udf | |
| .return_type(&[DataType::Int32, DataType::Int32]) | |
| .is_err()); | |
| let err = spark_map_extract( | |
| &ColumnarValue::Array(Arc::new(Int32Array::from(vec![1]))), | |
| &ColumnarValue::Scalar(ScalarValue::Int32(Some(1))), | |
| 1, | |
| ) | |
| .unwrap_err(); | |
| assert!(err.to_string().contains("must be a map")); | |
| } | |
| #[test] | |
| fn mismatched_key_type_is_rejected() { | |
| // A key the planner did not cast to the map's key type cannot be compared. Erroring keeps | |
| // the mismatch visible instead of reporting every row as a miss. | |
| let err = spark_map_extract( | |
| &ColumnarValue::Array(Arc::new(test_map())), | |
| &ColumnarValue::Scalar(ScalarValue::Int32(Some(1))), | |
| 5, | |
| ) | |
| .unwrap_err(); | |
| assert!(err.to_string().contains("does not match the map key type")); | |
| } | |
| #[test] | |
| fn argument_checks_do_not_depend_on_the_data() { | |
| // The key type and the key count are properties of the call, so a batch that happens to | |
| // hold no entries has to fail the same way as one that does. Checking them after the | |
| // empty-window fast path would make the same query answer NULLs on one partition and | |
| // error on the next. | |
| let empty = || ColumnarValue::Array(Arc::new(map_from(vec![Some(vec![]), None]))); | |
| let err = spark_map_extract( | |
| &empty(), | |
| &ColumnarValue::Scalar(ScalarValue::Int32(Some(1))), | |
| 2, | |
| ) | |
| .unwrap_err(); | |
| assert!(err.to_string().contains("does not match the map key type")); | |
| let short_keys: ArrayRef = Arc::new(StringArray::from(vec!["a"])); | |
| let err = spark_map_extract(&empty(), &ColumnarValue::Array(short_keys), 2).unwrap_err(); | |
| assert!(err.to_string().contains("expected 2 lookup keys, got 1")); | |
| } | |
| #[test] | |
| fn overrides_both_registry_entries_of_the_kernel_it_replaces() { | |
| // `register_udf` inserts one entry per alias, and DataFusion's `map_extract` declares | |
| // `element_at`. Without the same alias the override would leave `element_at` resolving to | |
| // the list-returning kernel. | |
| assert_eq!(SparkMapExtract::new().aliases(), ["element_at"]); | |
| } | |
| #[test] | |
| fn nested_key_falls_back_to_elementwise_comparison() { | |
| // `eq` refuses nested types. `MapKeySupport` keeps these on Spark, but the backstop has to | |
| // still find the key rather than error. | |
| let inner = Arc::new(Field::new("item", DataType::Int32, true)); | |
| let key_values = arrow::array::ListArray::new( | |
| Arc::clone(&inner), | |
| OffsetBuffer::new(vec![0i32, 1, 2].into()), | |
| Arc::new(Int32Array::from(vec![1, 2])) as ArrayRef, | |
| None, | |
| ); | |
| let map = map_of( | |
| Arc::new(key_values), | |
| Arc::new(Int32Array::from(vec![11, 22])), | |
| vec![0, 2], | |
| None, | |
| ); | |
| let lookup: ArrayRef = Arc::new(arrow::array::ListArray::new( | |
| inner, | |
| OffsetBuffer::new(vec![0i32, 1].into()), | |
| Arc::new(Int32Array::from(vec![2])) as ArrayRef, | |
| None, | |
| )); | |
| let result = spark_map_extract( | |
| &ColumnarValue::Array(Arc::new(map)), | |
| &ColumnarValue::Array(lookup), | |
| 1, | |
| ) | |
| .unwrap() | |
| .into_array(1) | |
| .unwrap(); | |
| let result = result.as_any().downcast_ref::<Int32Array>().unwrap(); | |
| assert_eq!(result.value(0), 22); | |
| } | |
| } |