1515// specific language governing permissions and limitations
1616// under the License.
1717
18- use std:: any:: Any ;
1918use std:: sync:: Arc ;
2019
2120use arrow:: array:: { Array , ArrayRef , Int32Array , Int64Array , OffsetSizeTrait } ;
@@ -83,10 +82,6 @@ impl SparkLevenshtein {
8382}
8483
8584impl ScalarUDFImpl for SparkLevenshtein {
86- fn as_any ( & self ) -> & dyn Any {
87- self
88- }
89-
9085 fn name ( & self ) -> & str {
9186 "levenshtein"
9287 }
@@ -122,14 +117,40 @@ impl ScalarUDFImpl for SparkLevenshtein {
122117 fn invoke_with_args ( & self , args : ScalarFunctionArgs ) -> Result < ColumnarValue > {
123118 let ScalarFunctionArgs { args, .. } = args;
124119
125- // Determine the coerced string type (handles mixed Utf8 + LargeUtf8)
120+ // Arity is validated in `return_type` at planning time, but invoke can
121+ // also be reached via direct UDF calls (tests, programmatic invocation)
122+ // so we re-check here before indexing `args[0]` / `args[1]`.
123+ if args. len ( ) != 2 && args. len ( ) != 3 {
124+ return exec_err ! ( "levenshtein expects 2 or 3 arguments, got {}" , args. len( ) ) ;
125+ }
126+
127+ // Determine the coerced string type (handles mixed Utf8 + LargeUtf8).
128+ // If neither string nor binary→string coercion applies, error out
129+ // explicitly — mirrors `return_type`'s behaviour instead of silently
130+ // defaulting to Utf8.
126131 let coerced_type = string_coercion ( & args[ 0 ] . data_type ( ) , & args[ 1 ] . data_type ( ) )
127132 . or_else ( || {
128133 binary_to_string_coercion ( & args[ 0 ] . data_type ( ) , & args[ 1 ] . data_type ( ) )
129134 } )
130- . unwrap_or ( DataType :: Utf8 ) ;
131-
132- // Spark returns NULL when any scalar argument is NULL.
135+ . ok_or_else ( || {
136+ datafusion_common:: DataFusionError :: Execution ( format ! (
137+ "Unsupported data types for levenshtein. Expected Utf8, \
138+ LargeUtf8 or Utf8View, got {:?} and {:?}",
139+ args[ 0 ] . data_type( ) ,
140+ args[ 1 ] . data_type( )
141+ ) )
142+ } ) ?;
143+
144+ // Spark NULL behaviour for `levenshtein`:
145+ // - NULL string argument (scalar OR per-row) → result is NULL
146+ // - NULL threshold scalar → result is NULL
147+ // - NULL threshold value PER ROW → treated as `0`
148+ // (handled inside `apply_threshold_*` in the kernel, NOT here)
149+ //
150+ // The per-row asymmetry (string-null propagates, threshold-null does
151+ // not) is verified Spark behaviour — see the Sail Gherkin scenario
152+ // "null threshold per row treated as zero" in
153+ // `python/pysail/tests/spark/function/features/levenshtein.feature`.
133154 let null_int = |dt : & DataType | match dt {
134155 DataType :: LargeUtf8 => ColumnarValue :: Scalar ( ScalarValue :: Int64 ( None ) ) ,
135156 _ => ColumnarValue :: Scalar ( ScalarValue :: Int32 ( None ) ) ,
@@ -154,6 +175,50 @@ impl ScalarUDFImpl for SparkLevenshtein {
154175 }
155176}
156177
178+ /// Applies the optional per-row threshold to a computed Levenshtein distance,
179+ /// returning an `i32` result. Used by the `Utf8` / `Utf8View` paths.
180+ ///
181+ /// Spark semantics — verified against PySpark and codified in the Sail
182+ /// test suite (`spark/function/features/levenshtein.feature`, scenario
183+ /// "null threshold per row treated as zero"):
184+ ///
185+ /// - No threshold column at all → return the raw distance.
186+ /// - Non-null threshold value → return `-1` if `dist > threshold`, else `dist`.
187+ /// - NULL threshold value at this row → treat as `0`, so any non-zero distance
188+ /// returns `-1` and identical strings (distance 0) return `0`.
189+ ///
190+ /// This matches Spark's behaviour where a NULL threshold per row is NOT
191+ /// null-propagating; only NULL string arguments produce a NULL result.
192+ #[ inline]
193+ fn apply_threshold_i32 ( dist : i32 , threshold : Option < ( & Int32Array , usize ) > ) -> i32 {
194+ match threshold {
195+ Some ( ( arr, i) ) => {
196+ let t = if arr. is_null ( i) { 0 } else { arr. value ( i) } ;
197+ if dist > t { -1 } else { dist }
198+ }
199+ None => dist,
200+ }
201+ }
202+
203+ /// `i64` counterpart of [`apply_threshold_i32`], used by the `LargeUtf8` path
204+ /// where the distance is materialised as `i64`. The threshold column itself
205+ /// remains `Int32` (Spark's signature), so the value is widened to `i64`
206+ /// before comparison.
207+ #[ inline]
208+ fn apply_threshold_i64 ( dist : i64 , threshold : Option < ( & Int32Array , usize ) > ) -> i64 {
209+ match threshold {
210+ Some ( ( arr, i) ) => {
211+ let t = if arr. is_null ( i) {
212+ 0
213+ } else {
214+ arr. value ( i) as i64
215+ } ;
216+ if dist > t { -1 } else { dist }
217+ }
218+ None => dist,
219+ }
220+ }
221+
157222/// Spark-compatible Levenshtein distance with optional threshold.
158223fn spark_levenshtein < T : OffsetSizeTrait > ( args : & [ ArrayRef ] ) -> Result < ArrayRef > {
159224 if args. len ( ) < 2 || args. len ( ) > 3 {
@@ -197,18 +262,10 @@ fn spark_levenshtein<T: OffsetSizeTrait>(args: &[ArrayRef]) -> Result<ArrayRef>
197262 let dist = datafusion_strsim:: levenshtein_with_buffer (
198263 string1, string2, & mut cache,
199264 ) as i32 ;
200- match & threshold {
201- Some ( t) => {
202- let thresh =
203- if t. is_null ( i) { 0 } else { t. value ( i) } ;
204- if dist > thresh {
205- Some ( -1i32 )
206- } else {
207- Some ( dist)
208- }
209- }
210- None => Some ( dist) ,
211- }
265+ Some ( apply_threshold_i32 (
266+ dist,
267+ threshold. as_ref ( ) . map ( |t| ( * t, i) ) ,
268+ ) )
212269 }
213270 _ => None ,
214271 } )
@@ -229,18 +286,10 @@ fn spark_levenshtein<T: OffsetSizeTrait>(args: &[ArrayRef]) -> Result<ArrayRef>
229286 let dist = datafusion_strsim:: levenshtein_with_buffer (
230287 string1, string2, & mut cache,
231288 ) as i32 ;
232- match & threshold {
233- Some ( t) => {
234- let thresh =
235- if t. is_null ( i) { 0 } else { t. value ( i) } ;
236- if dist > thresh {
237- Some ( -1i32 )
238- } else {
239- Some ( dist)
240- }
241- }
242- None => Some ( dist) ,
243- }
289+ Some ( apply_threshold_i32 (
290+ dist,
291+ threshold. as_ref ( ) . map ( |t| ( * t, i) ) ,
292+ ) )
244293 }
245294 _ => None ,
246295 } )
@@ -261,18 +310,10 @@ fn spark_levenshtein<T: OffsetSizeTrait>(args: &[ArrayRef]) -> Result<ArrayRef>
261310 let dist = datafusion_strsim:: levenshtein_with_buffer (
262311 string1, string2, & mut cache,
263312 ) as i64 ;
264- match & threshold {
265- Some ( t) => {
266- let thresh =
267- if t. is_null ( i) { 0 } else { t. value ( i) as i64 } ;
268- if dist > thresh {
269- Some ( -1i64 )
270- } else {
271- Some ( dist)
272- }
273- }
274- None => Some ( dist) ,
275- }
313+ Some ( apply_threshold_i64 (
314+ dist,
315+ threshold. as_ref ( ) . map ( |t| ( * t, i) ) ,
316+ ) )
276317 }
277318 _ => None ,
278319 } )
@@ -591,4 +632,88 @@ mod tests {
591632 assert_eq ! ( & Int64Array :: from( vec![ 3i64 ] ) , result) ;
592633 Ok ( ( ) )
593634 }
635+
636+ // ── Utf8View path ───────────────────────────────────────────
637+
638+ #[ test]
639+ fn test_utf8view_two_arg ( ) -> Result < ( ) > {
640+ use arrow:: array:: StringViewArray ;
641+ let s1: ArrayRef = Arc :: new ( StringViewArray :: from ( vec ! [ "kitten" ] ) ) ;
642+ let s2: ArrayRef = Arc :: new ( StringViewArray :: from ( vec ! [ "sitting" ] ) ) ;
643+ let res = spark_levenshtein :: < i32 > ( & [ s1, s2] ) ?;
644+ let result = as_int32_array ( & res) ?;
645+ assert_eq ! ( & Int32Array :: from( vec![ 3 ] ) , result) ;
646+ Ok ( ( ) )
647+ }
648+
649+ #[ test]
650+ fn test_utf8view_with_threshold_exceeds ( ) -> Result < ( ) > {
651+ use arrow:: array:: StringViewArray ;
652+ let s1: ArrayRef = Arc :: new ( StringViewArray :: from ( vec ! [ "kitten" ] ) ) ;
653+ let s2: ArrayRef = Arc :: new ( StringViewArray :: from ( vec ! [ "sitting" ] ) ) ;
654+ let t: ArrayRef = Arc :: new ( Int32Array :: from ( vec ! [ 2 ] ) ) ;
655+ // distance 3 > threshold 2 → -1
656+ let res = spark_levenshtein :: < i32 > ( & [ s1, s2, t] ) ?;
657+ let result = as_int32_array ( & res) ?;
658+ assert_eq ! ( & Int32Array :: from( vec![ -1 ] ) , result) ;
659+ Ok ( ( ) )
660+ }
661+
662+ #[ test]
663+ fn test_utf8view_with_threshold_within ( ) -> Result < ( ) > {
664+ use arrow:: array:: StringViewArray ;
665+ let s1: ArrayRef = Arc :: new ( StringViewArray :: from ( vec ! [ "kitten" ] ) ) ;
666+ let s2: ArrayRef = Arc :: new ( StringViewArray :: from ( vec ! [ "sitting" ] ) ) ;
667+ let t: ArrayRef = Arc :: new ( Int32Array :: from ( vec ! [ 5 ] ) ) ;
668+ // distance 3 <= threshold 5 → 3
669+ let res = spark_levenshtein :: < i32 > ( & [ s1, s2, t] ) ?;
670+ let result = as_int32_array ( & res) ?;
671+ assert_eq ! ( & Int32Array :: from( vec![ 3 ] ) , result) ;
672+ Ok ( ( ) )
673+ }
674+
675+ // ── LargeUtf8 + threshold path ──────────────────────────────
676+
677+ #[ test]
678+ fn test_largeutf8_with_threshold_exceeds ( ) -> Result < ( ) > {
679+ use arrow:: array:: LargeStringArray ;
680+ use datafusion_common:: cast:: as_int64_array;
681+ let s1: ArrayRef = Arc :: new ( LargeStringArray :: from ( vec ! [ "kitten" ] ) ) ;
682+ let s2: ArrayRef = Arc :: new ( LargeStringArray :: from ( vec ! [ "sitting" ] ) ) ;
683+ let t: ArrayRef = Arc :: new ( Int32Array :: from ( vec ! [ 2 ] ) ) ;
684+ // distance 3 > threshold 2 → -1 (as i64)
685+ let res = spark_levenshtein :: < i64 > ( & [ s1, s2, t] ) ?;
686+ let result = as_int64_array ( & res) ?;
687+ assert_eq ! ( & Int64Array :: from( vec![ -1i64 ] ) , result) ;
688+ Ok ( ( ) )
689+ }
690+
691+ #[ test]
692+ fn test_largeutf8_with_threshold_within ( ) -> Result < ( ) > {
693+ use arrow:: array:: LargeStringArray ;
694+ use datafusion_common:: cast:: as_int64_array;
695+ let s1: ArrayRef = Arc :: new ( LargeStringArray :: from ( vec ! [ "kitten" ] ) ) ;
696+ let s2: ArrayRef = Arc :: new ( LargeStringArray :: from ( vec ! [ "sitting" ] ) ) ;
697+ let t: ArrayRef = Arc :: new ( Int32Array :: from ( vec ! [ 5 ] ) ) ;
698+ // distance 3 <= threshold 5 → 3 (as i64)
699+ let res = spark_levenshtein :: < i64 > ( & [ s1, s2, t] ) ?;
700+ let result = as_int64_array ( & res) ?;
701+ assert_eq ! ( & Int64Array :: from( vec![ 3i64 ] ) , result) ;
702+ Ok ( ( ) )
703+ }
704+
705+ #[ test]
706+ fn test_largeutf8_with_null_threshold_row_treated_as_zero ( ) -> Result < ( ) > {
707+ use arrow:: array:: LargeStringArray ;
708+ use datafusion_common:: cast:: as_int64_array;
709+ // Row 0: "abc" vs "def" with NULL threshold → treated as 0 → -1
710+ // Row 1: "xyz" vs "xyz" with NULL threshold → distance 0 → 0
711+ let s1: ArrayRef = Arc :: new ( LargeStringArray :: from ( vec ! [ "abc" , "xyz" ] ) ) ;
712+ let s2: ArrayRef = Arc :: new ( LargeStringArray :: from ( vec ! [ "def" , "xyz" ] ) ) ;
713+ let t: ArrayRef = Arc :: new ( Int32Array :: from ( vec ! [ None , None ] ) ) ;
714+ let res = spark_levenshtein :: < i64 > ( & [ s1, s2, t] ) ?;
715+ let result = as_int64_array ( & res) ?;
716+ assert_eq ! ( & Int64Array :: from( vec![ -1i64 , 0i64 ] ) , result) ;
717+ Ok ( ( ) )
718+ }
594719}
0 commit comments