This is an automated email from the ASF dual-hosted git repository.

andygrove pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/datafusion-comet.git


The following commit(s) were added to refs/heads/main by this push:
     new 756ddd7e14 feat: Support Spark levenshtein expression in native 
execution (#4105)
756ddd7e14 is described below

commit 756ddd7e1457bf97d784c3f04810fcee766140a4
Author: Myx778 <[email protected]>
AuthorDate: Sat Jul 25 01:30:55 2026 +0800

    feat: Support Spark levenshtein expression in native execution (#4105)
    
    * feat: Support Spark levenshtein expression in native execution
    
    Implements the Levenshtein edit distance function as a native Comet
    scalar UDF, enabling Spark's `levenshtein(str1, str2)` to run via
    DataFusion instead of falling back to JVM.
    
    Implementation:
    - Rust kernel: O(min(m,n)) space DP algorithm with Unicode char-level
      distance computation and proper NULL propagation
    - Scala serde: Register via CometScalarFunction("levenshtein") in
      QueryPlanSerde stringExpressions map
    - Tests: Basic, NULL handling, and Unicode test cases
    
    Closes #3084
    
    * style: fix Spotless formatting violations in test code
    
    - Reformat Seq literal to match scalafmt maxColumn=98 constraint
    - Break long sql() call into multi-line format
    - Reformat unicode test data as multi-line Seq
    
    * fix: address CI lint failures
    
    - Fix Clippy needless_range_loop: use iter_mut().enumerate() instead
      of indexing loop for initializing prev array
    - Fix Scalafix unused import: remove Levenshtein from strings.scala
      imports (it's used via wildcard import in QueryPlanSerde.scala)
    
    * test: add SLT tests for levenshtein expression
    
    Add SQL logic test file covering:
    - Column arguments with NULL propagation
    - Column + literal combinations
    - Literal + literal edge cases
    - Identical string comparison
    - Unicode character-level distance
    
    * Add levenshtein to benchmark and update expressions.md
    
    - Add levenshtein benchmark entry in CometStringExpressionBenchmark
    - Update expressions.md to list Levenshtein as supported
    
    * Support three-argument levenshtein with threshold and add collation check
    
    - Implement threshold semantics: levenshtein(str1, str2, threshold) returns
      -1 when distance exceeds threshold, matching Spark behavior
    - Add CometLevenshtein serde with getSupportLevel that falls back for
      non-default collations (Spark 4 StringTypeWithCollation)
    - Add Rust tests for threshold and NULL threshold cases
    - Add SLT tests for threshold variants
    - Add Scala integration test for threshold
    - Add levenshtein_threshold to benchmark
    
    * Fix Spotless formatting in levenshtein threshold test
    
    * Fix Spotless formatting in strings.scala and test suite
    
    * Use scalarFunctionExprToProtoWithReturnType for levenshtein
    
    Override convert() to use scalarFunctionExprToProtoWithReturnType
    with IntegerType, so the native planner skips the DataFusion
    registry lookup and does not conflict with DataFusion's built-in
    2-arg levenshtein function when 3 args (threshold) are passed.
    
    Also fix Spotless: remove unnecessary string interpolation prefix.
    
    * Fix Spark 3.4 compatibility: skip levenshtein threshold tests on Spark < 
3.5
    
    The three-argument levenshtein(str1, str2, threshold) is only available
    in Spark 3.5+. This moves threshold-related SQL tests to a separate file
    with MinSparkVersion: 3.5 and adds assume(isSpark35Plus) to the unit test.
    
    * Remove duplicate levenshtein benchmark entry
    
    * Support threshold as column (not just literal) in levenshtein
    
    - Refactored spark_levenshtein to handle threshold as either
      ColumnarValue::Scalar or ColumnarValue::Array using into_array()
    - NULL threshold in a column produces NULL result for that row
    - Added Rust unit tests for array threshold, nulls, and negative values
    - Added Scala integration tests with threshold as column reference
    - Added SQL file tests with threshold column and NULL scenarios
    
    * Fix unused import warning: move ScalarValue to test module
    
    * Fix Spotless formatting in threshold column tests
    
    * Run cargo fmt
    
    ---------
    
    Co-authored-by: yusinnmao <[email protected]>
    Co-authored-by: Cursor <[email protected]>
---
 native/spark-expr/src/comet_scalar_funcs.rs        |   4 +
 native/spark-expr/src/string_funcs/levenshtein.rs  | 485 +++++++++++++++++++++
 native/spark-expr/src/string_funcs/mod.rs          |   2 +
 .../scala/org/apache/comet/serde/strings.scala     |  27 +-
 .../sql-tests/expressions/string/levenshtein.sql   |  30 +-
 .../expressions/string/levenshtein_threshold.sql   |  60 +++
 .../scala/org/apache/comet/CometCodegenSuite.scala |  14 +-
 .../benchmark/CometStringExpressionBenchmark.scala |   4 +
 8 files changed, 609 insertions(+), 17 deletions(-)

diff --git a/native/spark-expr/src/comet_scalar_funcs.rs 
b/native/spark-expr/src/comet_scalar_funcs.rs
index 98d78e0e67..8e913dabab 100644
--- a/native/spark-expr/src/comet_scalar_funcs.rs
+++ b/native/spark-expr/src/comet_scalar_funcs.rs
@@ -233,6 +233,10 @@ pub fn create_comet_physical_fun_with_eval_mode(
         "next_day" => Ok(Arc::new(ScalarUDF::new_from_impl(SparkNextDay::new(
             fail_on_error,
         )))),
+        "levenshtein" => {
+            let func = Arc::new(crate::string_funcs::spark_levenshtein);
+            make_comet_scalar_udf!("levenshtein", func, without data_type)
+        }
         _ => registry.udf(fun_name).map_err(|e| {
             DataFusionError::Execution(format!(
                 "Function {fun_name} not found in the registry: {e}",
diff --git a/native/spark-expr/src/string_funcs/levenshtein.rs 
b/native/spark-expr/src/string_funcs/levenshtein.rs
new file mode 100644
index 0000000000..19a81f92ff
--- /dev/null
+++ b/native/spark-expr/src/string_funcs/levenshtein.rs
@@ -0,0 +1,485 @@
+// 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.
+
+//! Levenshtein distance expression implementation.
+//!
+//! Computes the Levenshtein edit distance between two strings,
+//! matching Apache Spark's `levenshtein(str1, str2)` semantics.
+
+use arrow::array::{Array, ArrayRef, GenericStringArray, Int32Array, 
OffsetSizeTrait};
+use arrow::datatypes::DataType;
+use datafusion::common::{cast::as_generic_string_array, DataFusionError, 
Result};
+use datafusion::physical_plan::ColumnarValue;
+use std::sync::Arc;
+
+/// Computes the Levenshtein edit distance between two UTF-8 strings.
+///
+/// This uses the standard dynamic programming algorithm with O(min(m,n)) 
space.
+fn levenshtein_distance(s: &str, t: &str) -> i32 {
+    let s_chars: Vec<char> = s.chars().collect();
+    let t_chars: Vec<char> = t.chars().collect();
+    let m = s_chars.len();
+    let n = t_chars.len();
+
+    // Optimization: if one string is empty, distance is the length of the 
other
+    if m == 0 {
+        return n as i32;
+    }
+    if n == 0 {
+        return m as i32;
+    }
+
+    // Use the shorter string for the "column" to minimize space usage
+    let (s_chars, t_chars, m, n) = if m > n {
+        (t_chars, s_chars, n, m)
+    } else {
+        (s_chars, t_chars, m, n)
+    };
+
+    // Previous and current row of distances
+    let mut prev = vec![0i32; m + 1];
+    let mut curr = vec![0i32; m + 1];
+
+    // Initialize base case: distance from empty string
+    for (i, val) in prev.iter_mut().enumerate() {
+        *val = i as i32;
+    }
+
+    for j in 1..=n {
+        curr[0] = j as i32;
+        for i in 1..=m {
+            let cost = if s_chars[i - 1] == t_chars[j - 1] {
+                0
+            } else {
+                1
+            };
+            curr[i] = (prev[i] + 1) // deletion
+                .min(curr[i - 1] + 1) // insertion
+                .min(prev[i - 1] + cost); // substitution
+        }
+        std::mem::swap(&mut prev, &mut curr);
+    }
+
+    prev[m]
+}
+
+/// Computes the Levenshtein distance up to `threshold` using a diagonal band.
+///
+/// Spark's three-argument form uses the threshold to avoid evaluating cells 
that cannot
+/// contribute to a result within the requested distance. This keeps the 
complexity at
+/// O(threshold * max(m, n)) when the threshold is small rather than always 
using O(m * n).
+fn levenshtein_distance_with_threshold(s: &str, t: &str, threshold: i32) -> 
i32 {
+    if threshold < 0 {
+        return -1;
+    }
+
+    let s_chars: Vec<char> = s.chars().collect();
+    let t_chars: Vec<char> = t.chars().collect();
+    let (shorter, longer) = if s_chars.len() <= t_chars.len() {
+        (s_chars, t_chars)
+    } else {
+        (t_chars, s_chars)
+    };
+    let m = shorter.len();
+    let n = longer.len();
+    let threshold = threshold as usize;
+
+    if n - m > threshold {
+        return -1;
+    }
+    if m == 0 {
+        return if n <= threshold { n as i32 } else { -1 };
+    }
+
+    let out_of_band = n.saturating_add(1);
+    let mut prev = vec![out_of_band; m + 1];
+    let mut curr = vec![out_of_band; m + 1];
+    for (i, value) in prev.iter_mut().enumerate().take(m.min(threshold) + 1) {
+        *value = i;
+    }
+
+    for j in 1..=n {
+        let start = 1.max(j.saturating_sub(threshold));
+        let end = m.min(j.saturating_add(threshold));
+        if start > end {
+            return -1;
+        }
+
+        curr[0] = if j <= threshold { j } else { out_of_band };
+        curr[start - 1] = if start == 1 { curr[0] } else { out_of_band };
+        for i in start..=end {
+            let cost = usize::from(shorter[i - 1] != longer[j - 1]);
+            curr[i] = prev[i]
+                .saturating_add(1)
+                .min(curr[i - 1].saturating_add(1))
+                .min(prev[i - 1].saturating_add(cost));
+        }
+        if end < m {
+            curr[end + 1] = out_of_band;
+        }
+        std::mem::swap(&mut prev, &mut curr);
+    }
+
+    if prev[m] <= threshold {
+        prev[m] as i32
+    } else {
+        -1
+    }
+}
+
+fn evaluate_levenshtein<LeftOffset, RightOffset>(
+    left: &GenericStringArray<LeftOffset>,
+    right: &GenericStringArray<RightOffset>,
+    threshold: Option<&Int32Array>,
+) -> Int32Array
+where
+    LeftOffset: OffsetSizeTrait,
+    RightOffset: OffsetSizeTrait,
+{
+    left.iter()
+        .zip(right.iter())
+        .enumerate()
+        .map(|(i, (left_value, right_value))| {
+            if threshold.is_some_and(|values| values.is_null(i)) {
+                return None;
+            }
+
+            match (left_value, right_value) {
+                (Some(left_value), Some(right_value)) => Some(match threshold {
+                    Some(values) => levenshtein_distance_with_threshold(
+                        left_value,
+                        right_value,
+                        values.value(i),
+                    ),
+                    None => levenshtein_distance(left_value, right_value),
+                }),
+                _ => None,
+            }
+        })
+        .collect()
+}
+
+fn evaluate_string_arrays(
+    left: &ArrayRef,
+    right: &ArrayRef,
+    threshold: Option<&Int32Array>,
+) -> Result<Int32Array> {
+    match (left.data_type(), right.data_type()) {
+        (DataType::Utf8, DataType::Utf8) => Ok(evaluate_levenshtein(
+            as_generic_string_array::<i32>(left.as_ref())?,
+            as_generic_string_array::<i32>(right.as_ref())?,
+            threshold,
+        )),
+        (DataType::Utf8, DataType::LargeUtf8) => Ok(evaluate_levenshtein(
+            as_generic_string_array::<i32>(left.as_ref())?,
+            as_generic_string_array::<i64>(right.as_ref())?,
+            threshold,
+        )),
+        (DataType::LargeUtf8, DataType::Utf8) => Ok(evaluate_levenshtein(
+            as_generic_string_array::<i64>(left.as_ref())?,
+            as_generic_string_array::<i32>(right.as_ref())?,
+            threshold,
+        )),
+        (DataType::LargeUtf8, DataType::LargeUtf8) => Ok(evaluate_levenshtein(
+            as_generic_string_array::<i64>(left.as_ref())?,
+            as_generic_string_array::<i64>(right.as_ref())?,
+            threshold,
+        )),
+        (left_type, right_type) => Err(DataFusionError::Execution(format!(
+            "levenshtein expects Utf8 or LargeUtf8 arguments, got 
{left_type:?} and {right_type:?}"
+        ))),
+    }
+}
+
+/// Spark-compatible levenshtein scalar function.
+///
+/// Accepts two or three arguments:
+/// - `levenshtein(str1, str2)` → edit distance
+/// - `levenshtein(str1, str2, threshold)` → edit distance if <= threshold, 
else -1
+///
+/// The threshold argument can be either a scalar or a column (array).
+/// NULL inputs produce NULL outputs. NULL threshold produces NULL output for 
that row.
+pub fn spark_levenshtein(args: &[ColumnarValue]) -> Result<ColumnarValue> {
+    if args.len() < 2 || args.len() > 3 {
+        return Err(DataFusionError::Internal(format!(
+            "levenshtein requires 2 or 3 arguments, got {}",
+            args.len()
+        )));
+    }
+
+    // Determine array length from any array argument
+    let len = args
+        .iter()
+        .find_map(|arg| match arg {
+            ColumnarValue::Array(a) => Some(a.len()),
+            _ => None,
+        })
+        .unwrap_or(1);
+
+    let left = args[0].clone().into_array(len)?;
+    let right = args[1].clone().into_array(len)?;
+    if left.len() != len || right.len() != len {
+        return Err(DataFusionError::Internal(
+            "levenshtein arguments must have the same length".to_string(),
+        ));
+    }
+
+    // Handle the optional threshold argument (scalar or array)
+    let threshold_array = if args.len() == 3 {
+        let threshold_array = args[2].clone().into_array(len)?;
+        if threshold_array.len() != len {
+            return Err(DataFusionError::Internal(
+                "levenshtein threshold must have the same length as string 
arguments".to_string(),
+            ));
+        }
+        Some(threshold_array)
+    } else {
+        None
+    };
+    let threshold = threshold_array
+        .as_ref()
+        .map(|array| {
+            array.as_any().downcast_ref::<Int32Array>().ok_or_else(|| {
+                DataFusionError::Internal("levenshtein threshold must be 
Int32".to_string())
+            })
+        })
+        .transpose()?;
+
+    let result = evaluate_string_arrays(&left, &right, threshold)?;
+    Ok(ColumnarValue::Array(Arc::new(result) as ArrayRef))
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use arrow::array::{LargeStringArray, StringArray};
+    use datafusion::common::ScalarValue;
+
+    #[test]
+    fn test_levenshtein_basic() {
+        assert_eq!(levenshtein_distance("", ""), 0);
+        assert_eq!(levenshtein_distance("abc", ""), 3);
+        assert_eq!(levenshtein_distance("", "abc"), 3);
+        assert_eq!(levenshtein_distance("abc", "abc"), 0);
+        assert_eq!(levenshtein_distance("kitten", "sitting"), 3);
+        assert_eq!(levenshtein_distance("frog", "fog"), 1);
+    }
+
+    #[test]
+    fn test_levenshtein_unicode() {
+        // Spark counts character-level (not byte-level) edit distance
+        assert_eq!(levenshtein_distance("你好", "你坏"), 1);
+        assert_eq!(levenshtein_distance("abc", "äbc"), 1);
+    }
+
+    #[test]
+    fn test_spark_levenshtein_nulls() {
+        let left = ColumnarValue::Array(Arc::new(StringArray::from(vec![
+            Some("abc"),
+            None,
+            Some("hello"),
+        ])));
+        let right = ColumnarValue::Array(Arc::new(StringArray::from(vec![
+            Some("adc"),
+            Some("test"),
+            None,
+        ])));
+
+        let result = spark_levenshtein(&[left, right]).unwrap();
+        match result {
+            ColumnarValue::Array(arr) => {
+                let int_arr = 
arr.as_any().downcast_ref::<Int32Array>().unwrap();
+                assert_eq!(int_arr.value(0), 1); // abc -> adc = 1
+                assert!(int_arr.is_null(1)); // NULL -> test = NULL
+                assert!(int_arr.is_null(2)); // hello -> NULL = NULL
+            }
+            _ => panic!("Expected array result"),
+        }
+    }
+
+    #[test]
+    fn test_spark_levenshtein_large_utf8() {
+        let left =
+            
ColumnarValue::Array(Arc::new(LargeStringArray::from(vec![Some("kitten"), 
None])));
+        let right = ColumnarValue::Array(Arc::new(LargeStringArray::from(vec![
+            Some("sitting"),
+            None,
+        ])));
+
+        let result = spark_levenshtein(&[left, right]).unwrap();
+        match result {
+            ColumnarValue::Array(arr) => {
+                let int_arr = 
arr.as_any().downcast_ref::<Int32Array>().unwrap();
+                assert_eq!(int_arr.value(0), 3);
+                assert!(int_arr.is_null(1));
+            }
+            _ => panic!("Expected array result"),
+        }
+    }
+
+    #[test]
+    fn test_spark_levenshtein_with_threshold() {
+        let left = ColumnarValue::Array(Arc::new(StringArray::from(vec![
+            Some("kitten"),
+            Some("abc"),
+            Some("frog"),
+        ])));
+        let right = ColumnarValue::Array(Arc::new(StringArray::from(vec![
+            Some("sitting"),
+            Some("adc"),
+            Some("fog"),
+        ])));
+        let threshold = ColumnarValue::Scalar(ScalarValue::Int32(Some(2)));
+
+        let result = spark_levenshtein(&[left, right, threshold]).unwrap();
+        match result {
+            ColumnarValue::Array(arr) => {
+                let int_arr = 
arr.as_any().downcast_ref::<Int32Array>().unwrap();
+                assert_eq!(int_arr.value(0), -1); // kitten->sitting=3 > 2, 
return -1
+                assert_eq!(int_arr.value(1), 1); // abc->adc=1 <= 2, return 1
+                assert_eq!(int_arr.value(2), 1); // frog->fog=1 <= 2, return 1
+            }
+            _ => panic!("Expected array result"),
+        }
+    }
+
+    #[test]
+    fn test_spark_levenshtein_null_threshold() {
+        let left = 
ColumnarValue::Array(Arc::new(StringArray::from(vec![Some("abc")])));
+        let right = 
ColumnarValue::Array(Arc::new(StringArray::from(vec![Some("adc")])));
+        let threshold = ColumnarValue::Scalar(ScalarValue::Int32(None));
+
+        let result = spark_levenshtein(&[left, right, threshold]).unwrap();
+        match result {
+            ColumnarValue::Array(arr) => {
+                let int_arr = 
arr.as_any().downcast_ref::<Int32Array>().unwrap();
+                assert_eq!(int_arr.len(), 1);
+                assert!(int_arr.is_null(0)); // NULL threshold -> NULL result
+            }
+            _ => panic!("Expected array result with NULL for NULL threshold"),
+        }
+    }
+
+    #[test]
+    fn test_spark_levenshtein_threshold_as_array() {
+        // threshold is a column (array) with per-row values
+        let left = ColumnarValue::Array(Arc::new(StringArray::from(vec![
+            Some("kitten"),
+            Some("frog"),
+            Some("abc"),
+            Some("hello"),
+        ])));
+        let right = ColumnarValue::Array(Arc::new(StringArray::from(vec![
+            Some("sitting"),
+            Some("fog"),
+            Some("abc"),
+            Some("world"),
+        ])));
+        // Per-row thresholds: 2, 5, 0, 3
+        let threshold = ColumnarValue::Array(Arc::new(Int32Array::from(vec![
+            Some(2),
+            Some(5),
+            Some(0),
+            Some(3),
+        ])));
+
+        let result = spark_levenshtein(&[left, right, threshold]).unwrap();
+        match result {
+            ColumnarValue::Array(arr) => {
+                let int_arr = 
arr.as_any().downcast_ref::<Int32Array>().unwrap();
+                assert_eq!(int_arr.value(0), -1); // kitten->sitting=3 > 2, 
return -1
+                assert_eq!(int_arr.value(1), 1); // frog->fog=1 <= 5, return 1
+                assert_eq!(int_arr.value(2), 0); // abc->abc=0 <= 0, return 0
+                assert_eq!(int_arr.value(3), -1); // hello->world=4 > 3, 
return -1
+            }
+            _ => panic!("Expected array result"),
+        }
+    }
+
+    #[test]
+    fn test_spark_levenshtein_threshold_array_with_nulls() {
+        // threshold array where some values are NULL
+        let left = ColumnarValue::Array(Arc::new(StringArray::from(vec![
+            Some("abc"),
+            Some("hello"),
+            Some("frog"),
+        ])));
+        let right = ColumnarValue::Array(Arc::new(StringArray::from(vec![
+            Some("adc"),
+            Some("world"),
+            Some("fog"),
+        ])));
+        let threshold = ColumnarValue::Array(Arc::new(Int32Array::from(vec![
+            Some(2),
+            None, // NULL threshold for this row
+            Some(0),
+        ])));
+
+        let result = spark_levenshtein(&[left, right, threshold]).unwrap();
+        match result {
+            ColumnarValue::Array(arr) => {
+                let int_arr = 
arr.as_any().downcast_ref::<Int32Array>().unwrap();
+                assert_eq!(int_arr.value(0), 1); // abc->adc=1 <= 2, return 1
+                assert!(int_arr.is_null(1)); // NULL threshold -> NULL
+                assert_eq!(int_arr.value(2), -1); // frog->fog=1 > 0, return -1
+            }
+            _ => panic!("Expected array result"),
+        }
+    }
+
+    #[test]
+    fn test_spark_levenshtein_threshold_negative() {
+        // Negative threshold means distance always exceeds threshold → return 
-1
+        let left =
+            ColumnarValue::Array(Arc::new(StringArray::from(vec![Some("abc"), 
Some("abc")])));
+        let right =
+            ColumnarValue::Array(Arc::new(StringArray::from(vec![Some("abc"), 
Some("adc")])));
+        let threshold = 
ColumnarValue::Array(Arc::new(Int32Array::from(vec![Some(-1), Some(-5)])));
+
+        let result = spark_levenshtein(&[left, right, threshold]).unwrap();
+        match result {
+            ColumnarValue::Array(arr) => {
+                let int_arr = 
arr.as_any().downcast_ref::<Int32Array>().unwrap();
+                // dist=0 > -1 is true, so return -1
+                assert_eq!(int_arr.value(0), -1);
+                // dist=1 > -5 is true, so return -1
+                assert_eq!(int_arr.value(1), -1);
+            }
+            _ => panic!("Expected array result"),
+        }
+    }
+
+    #[test]
+    fn test_levenshtein_threshold_banded() {
+        assert_eq!(
+            levenshtein_distance_with_threshold("kitten", "sitting", 2),
+            -1
+        );
+        assert_eq!(
+            levenshtein_distance_with_threshold("kitten", "sitting", 3),
+            3
+        );
+        assert_eq!(
+            levenshtein_distance_with_threshold("abcdefghij", "abxdefghij", 1),
+            1
+        );
+        assert_eq!(
+            levenshtein_distance_with_threshold("short", "a much longer 
string", 2),
+            -1
+        );
+    }
+}
diff --git a/native/spark-expr/src/string_funcs/mod.rs 
b/native/spark-expr/src/string_funcs/mod.rs
index ca425fa0d1..5dd422a04e 100644
--- a/native/spark-expr/src/string_funcs/mod.rs
+++ b/native/spark-expr/src/string_funcs/mod.rs
@@ -18,6 +18,7 @@
 mod base64;
 mod contains;
 mod get_json_object;
+mod levenshtein;
 mod regexp_extract;
 mod regexp_extract_all;
 mod regexp_extract_common;
@@ -26,6 +27,7 @@ mod split;
 pub use base64::spark_base64;
 pub use contains::SparkContains;
 pub use get_json_object::spark_get_json_object;
+pub use levenshtein::spark_levenshtein;
 pub use regexp_extract::spark_regexp_extract;
 pub use regexp_extract_all::spark_regexp_extract_all;
 pub use split::{spark_split, spark_split_sql};
diff --git a/spark/src/main/scala/org/apache/comet/serde/strings.scala 
b/spark/src/main/scala/org/apache/comet/serde/strings.scala
index 1e124dbd42..1482d3e79e 100644
--- a/spark/src/main/scala/org/apache/comet/serde/strings.scala
+++ b/spark/src/main/scala/org/apache/comet/serde/strings.scala
@@ -20,7 +20,7 @@
 package org.apache.comet.serde
 
 import org.apache.spark.sql.catalyst.expressions.{Attribute, Base64, 
BitLength, Cast, Concat, ConcatWs, Contains, Elt, Empty2Null, EndsWith, 
Expression, FindInSet, FormatNumber, FormatString, GetJsonObject, InitCap, 
Left, Length, Levenshtein, Like, Literal, Lower, Mask, OctetLength, Overlay, 
RegExpExtract, RegExpExtractAll, RegExpInStr, RegExpReplace, Right, RLike, 
SoundEx, StartsWith, StringLocate, StringLPad, StringRepeat, StringReplace, 
StringRPad, StringSplit, StringTranslate, Substr [...]
-import org.apache.spark.sql.types.{BinaryType, DataTypes, LongType, StringType}
+import org.apache.spark.sql.types.{BinaryType, DataTypes, IntegerType, 
LongType, StringType}
 
 import org.apache.comet.CometConf
 import org.apache.comet.serde.ExprOuterClass.Expr
@@ -120,6 +120,29 @@ object CometStringTranslate extends 
CometScalarFunction[StringTranslate]("transl
     Some(incompatReason))
 }
 
+object CometLevenshtein extends CometExpressionSerde[Levenshtein] {
+
+  override def getUnsupportedReasons(): Seq[String] = Seq(
+    "Non-default collation (non-UTF8_BINARY) is not supported")
+
+  override def getSupportLevel(expr: Levenshtein): SupportLevel =
+    if (expr.children.exists(child => 
QueryPlanSerde.isStringCollationType(child.dataType))) {
+      Unsupported(Some("Levenshtein with non-default collation is not 
supported"))
+    } else {
+      Compatible()
+    }
+
+  override def convert(
+      expr: Levenshtein,
+      inputs: Seq[Attribute],
+      binding: Boolean): Option[Expr] = {
+    val childExprs = expr.children.map(exprToProtoInternal(_, inputs, binding))
+    val optExpr =
+      scalarFunctionExprToProtoWithReturnType("levenshtein", IntegerType, 
false, childExprs: _*)
+    optExprWithFallbackReason(optExpr, expr, expr.children: _*)
+  }
+}
+
 object CometInitCap extends CometScalarFunction[InitCap]("initcap") with 
NativeOptInAvailable {
 
   override def getIncompatibleReasons(): Seq[String] =
@@ -655,8 +678,6 @@ object CometGetJsonObject extends 
CometCodegenDispatch[GetJsonObject] with Nativ
 
 // Expressions routed through the JVM codegen dispatcher: no native 
implementation, so Spark's own
 // doGenCode runs inside the Comet pipeline, matching Spark exactly.
-object CometLevenshtein extends CometCodegenDispatch[Levenshtein]
-
 object CometElt extends CometCodegenDispatch[Elt]
 
 object CometFindInSet extends CometCodegenDispatch[FindInSet]
diff --git 
a/spark/src/test/resources/sql-tests/expressions/string/levenshtein.sql 
b/spark/src/test/resources/sql-tests/expressions/string/levenshtein.sql
index 62c52c70e1..6ae8225cbe 100644
--- a/spark/src/test/resources/sql-tests/expressions/string/levenshtein.sql
+++ b/spark/src/test/resources/sql-tests/expressions/string/levenshtein.sql
@@ -15,17 +15,33 @@
 -- specific language governing permissions and limitations
 -- under the License.
 
--- Routes levenshtein through the codegen dispatcher so behavior matches Spark 
exactly.
-
 statement
-CREATE TABLE test_levenshtein(a string, b string) USING parquet
+CREATE TABLE test_levenshtein(s1 string, s2 string) USING parquet
 
 statement
-INSERT INTO test_levenshtein VALUES ('kitten', 'sitting'), ('', 'abc'), 
('abc', 'abc'), ('flaw', 'lawn'), (NULL, 'x'), ('x', NULL), (NULL, NULL)
+INSERT INTO test_levenshtein VALUES ('kitten', 'sitting'), ('frog', 'fog'), 
('abc', 'abc'), ('', 'hello'), ('hello', ''), ('', ''), (NULL, 'test'), 
('hello', NULL), (NULL, NULL)
+
+-- column arguments
+query
+SELECT levenshtein(s1, s2) FROM test_levenshtein
+
+-- column + literal
+query
+SELECT levenshtein(s1, 'abc') FROM test_levenshtein
 
+-- literal + column
 query
-SELECT a, b, levenshtein(a, b) FROM test_levenshtein
+SELECT levenshtein('kitten', s2) FROM test_levenshtein
 
--- literal arguments
+-- literal + literal
 query
-SELECT levenshtein('kitten', 'sitting'), levenshtein('', ''), 
levenshtein(NULL, 'x')
+SELECT levenshtein('kitten', 'sitting'), levenshtein('frog', 'fog'), 
levenshtein('', ''), levenshtein(NULL, 'a')
+
+-- identical strings
+query
+SELECT levenshtein(s1, s1) FROM test_levenshtein
+
+-- unicode characters
+query
+SELECT levenshtein('café', 'cafe'), levenshtein('你好', '你坏')
+
diff --git 
a/spark/src/test/resources/sql-tests/expressions/string/levenshtein_threshold.sql
 
b/spark/src/test/resources/sql-tests/expressions/string/levenshtein_threshold.sql
new file mode 100644
index 0000000000..a0756a3403
--- /dev/null
+++ 
b/spark/src/test/resources/sql-tests/expressions/string/levenshtein_threshold.sql
@@ -0,0 +1,60 @@
+-- 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.
+
+-- MinSparkVersion: 3.5
+
+statement
+CREATE TABLE test_levenshtein(s1 string, s2 string) USING parquet
+
+statement
+INSERT INTO test_levenshtein VALUES ('kitten', 'sitting'), ('frog', 'fog'), 
('abc', 'abc'), ('', 'hello'), ('hello', ''), ('', ''), (NULL, 'test'), 
('hello', NULL), (NULL, NULL)
+
+-- three argument version with threshold
+query
+SELECT levenshtein('kitten', 'sitting', 2), levenshtein('kitten', 'sitting', 
3), levenshtein('kitten', 'sitting', 4)
+
+-- threshold with column arguments
+query
+SELECT levenshtein(s1, s2, 2) FROM test_levenshtein
+
+-- threshold edge cases
+query
+SELECT levenshtein('abc', 'abc', 0), levenshtein('abc', 'adc', 0), 
levenshtein('', '', 0)
+
+-- threshold with NULL
+query
+SELECT levenshtein('abc', 'adc', NULL), levenshtein(NULL, 'test', 2)
+
+-- threshold as column
+statement
+CREATE TABLE test_levenshtein_col(s1 string, s2 string, threshold int) USING 
parquet
+
+statement
+INSERT INTO test_levenshtein_col VALUES ('kitten', 'sitting', 2), ('frog', 
'fog', 5), ('abc', 'abc', 0), ('hello', 'world', 3)
+
+query
+SELECT levenshtein(s1, s2, threshold) FROM test_levenshtein_col
+
+-- threshold as column with NULLs
+statement
+CREATE TABLE test_levenshtein_col_nulls(s1 string, s2 string, threshold int) 
USING parquet
+
+statement
+INSERT INTO test_levenshtein_col_nulls VALUES ('abc', 'adc', 2), ('hello', 
'world', NULL), (NULL, 'test', 3), ('frog', 'fog', -1)
+
+query
+SELECT levenshtein(s1, s2, threshold) FROM test_levenshtein_col_nulls
diff --git a/spark/src/test/scala/org/apache/comet/CometCodegenSuite.scala 
b/spark/src/test/scala/org/apache/comet/CometCodegenSuite.scala
index aa610d0751..068e698fa6 100644
--- a/spark/src/test/scala/org/apache/comet/CometCodegenSuite.scala
+++ b/spark/src/test/scala/org/apache/comet/CometCodegenSuite.scala
@@ -132,12 +132,12 @@ class CometCodegenSuite
   }
 
   test("explainCodegen.enabled surfaces routed expressions in COMET-INFO") {
-    // With the opt-in flag on, `hypot` and `levenshtein` (both 
`CometCodegenDispatch`) roll up
-    // into one `[COMET-INFO: JVM codegen dispatcher: hypot, levenshtein]` 
line on the
+    // With the opt-in flag on, `hypot` and `nanvl` (both 
`CometCodegenDispatch`) roll up
+    // into one `[COMET-INFO: JVM codegen dispatcher: hypot, nanvl]` line on 
the
     // `CometProject`. With the flag off (default), no such line appears.
     withTable("t") {
-      sql("CREATE TABLE t (a DOUBLE, b DOUBLE, s1 STRING, s2 STRING) USING 
parquet")
-      sql("INSERT INTO t VALUES (3.0, 4.0, 'kitten', 'sitting')")
+      sql("CREATE TABLE t (a DOUBLE, b DOUBLE) USING parquet")
+      sql("INSERT INTO t VALUES (3.0, 4.0)")
 
       withSQLConf(
         CometConf.COMET_SCALA_UDF_CODEGEN_ENABLED.key -> "true",
@@ -145,7 +145,7 @@ class CometCodegenSuite
         CometConf.COMET_EXEC_PROJECT_ENABLED.key -> "true",
         CometConf.COMET_EXTENDED_EXPLAIN_FORMAT.key ->
           CometConf.COMET_EXTENDED_EXPLAIN_FORMAT_VERBOSE) {
-        val df = sql("SELECT hypot(a, b), levenshtein(s1, s2) FROM t")
+        val df = sql("SELECT hypot(a, b), nanvl(a, b) FROM t")
         checkSparkAnswerAndOperator(df)
         val explain =
           new 
ExtendedExplainInfo().generateExtendedInfo(df.queryExecution.executedPlan)
@@ -154,7 +154,7 @@ class CometCodegenSuite
           s"expected a [COMET-INFO: segment, got:\n$explain")
         // Names appear alphabetically via `.distinct.sorted` in 
rollUpInfoMessages.
         assert(
-          explain.contains("JVM codegen dispatcher: hypot, levenshtein"),
+          explain.contains("JVM codegen dispatcher: hypot, nanvl"),
           s"expected combined codegen-dispatch info, got:\n$explain")
       }
 
@@ -164,7 +164,7 @@ class CometCodegenSuite
         CometConf.COMET_EXEC_PROJECT_ENABLED.key -> "true",
         CometConf.COMET_EXTENDED_EXPLAIN_FORMAT.key ->
           CometConf.COMET_EXTENDED_EXPLAIN_FORMAT_VERBOSE) {
-        val df = sql("SELECT hypot(a, b), levenshtein(s1, s2) FROM t")
+        val df = sql("SELECT hypot(a, b), nanvl(a, b) FROM t")
         checkSparkAnswerAndOperator(df)
         val explain =
           new 
ExtendedExplainInfo().generateExtendedInfo(df.queryExecution.executedPlan)
diff --git 
a/spark/src/test/scala/org/apache/spark/sql/benchmark/CometStringExpressionBenchmark.scala
 
b/spark/src/test/scala/org/apache/spark/sql/benchmark/CometStringExpressionBenchmark.scala
index 653ca6fd0b..2b862f9e60 100644
--- 
a/spark/src/test/scala/org/apache/spark/sql/benchmark/CometStringExpressionBenchmark.scala
+++ 
b/spark/src/test/scala/org/apache/spark/sql/benchmark/CometStringExpressionBenchmark.scala
@@ -57,6 +57,10 @@ object CometStringExpressionBenchmark extends 
CometBenchmarkBase {
     StringExprConfig("initCap", "select initCap(c1) from parquetV1Table"),
     StringExprConfig("instr", "select instr(c1, '123') from parquetV1Table"),
     StringExprConfig("length", "select length(c1) from parquetV1Table"),
+    StringExprConfig("levenshtein", "select levenshtein(c1, 'test') from 
parquetV1Table"),
+    StringExprConfig(
+      "levenshtein_threshold",
+      "select levenshtein(c1, 'test', 3) from parquetV1Table"),
     StringExprConfig("like", "select c1 like '%123%' from parquetV1Table"),
     StringExprConfig("lower", "select lower(c1) from parquetV1Table"),
     StringExprConfig("lpad", "select lpad(c1, 150, 'x') from parquetV1Table"),


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]


Reply via email to