Skip to content

Commit e5f7af1

Browse files
feat(spark): add concat_ws with array support (#20928)
## Which issue does this PR close? - Part of #15914 ## Rationale for this change DataFusion core's `concat_ws` does not support array arguments. Spark's `concat_ws(sep, ...)` accepts both scalar strings and arrays, expanding array elements and skipping nulls. This is needed for Spark compatibility in the `datafusion-spark` crate. ## What changes are included in this PR? - New `SparkConcatWs` UDF in `datafusion/spark/src/function/string/concat_ws.rs` - Supports `concat_ws(sep, str1, str2, ...)` with scalar strings - Supports array arguments: `concat_ws(',', array('a', 'b'), 'c')` → `"a,b,c"` - Null scalars and null array elements are skipped (Spark behavior) - Null separator returns NULL - Zero value arguments (`concat_ws(',')`) returns empty string - Supports Utf8, LargeUtf8, Utf8View, List, and LargeList types - Registered the function in `mod.rs` (`make_udf_function!`, `export_functions!`, `functions()`) - Replaced commented-out SLT tests with 14 working test cases covering basic usage, arrays, mixed arguments, nulls, column expressions, and edge cases ## Are these changes tested? Yes. - 7 unit tests in `concat_ws.rs` (basic, null values skipped, null separator, list arrays, list with nulls, mixed scalar+list, multiple rows) - 14 SLT tests in `spark/string/concat_ws.slt` covering scalars, arrays, nulls, column expressions, and edge cases ## Are there any user-facing changes? No. This is a new function in the `datafusion-spark` crate only.
1 parent f931728 commit e5f7af1

3 files changed

Lines changed: 669 additions & 19 deletions

File tree

Lines changed: 297 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
1+
// Licensed to the Apache Software Foundation (ASF) under one
2+
// or more contributor license agreements. See the NOTICE file
3+
// distributed with this work for additional information
4+
// regarding copyright ownership. The ASF licenses this file
5+
// to you under the Apache License, Version 2.0 (the
6+
// "License"); you may not use this file except in compliance
7+
// with the License. You may obtain a copy of the License at
8+
//
9+
// http://www.apache.org/licenses/LICENSE-2.0
10+
//
11+
// Unless required by applicable law or agreed to in writing,
12+
// software distributed under the License is distributed on an
13+
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
// KIND, either express or implied. See the License for the
15+
// specific language governing permissions and limitations
16+
// under the License.
17+
18+
//! Spark-compatible `concat_ws`: joins strings (and array elements) with a separator.
19+
//!
20+
//! Null scalar args and null array elements are skipped; a null separator yields a
21+
//! null row. Non-string args are coerced to STRING; list args (`List`, `LargeList`,
22+
//! `ListView`, `LargeListView`, `FixedSizeList`) expand their elements.
23+
//!
24+
//! Differences with DataFusion core `concat_ws`:
25+
//! - Accepts list arguments and expands their elements
26+
//! - Always returns Utf8 (Spark's `STRING` type)
27+
//! - Coerces non-string scalars (numbers, booleans, dates, ...) to Utf8
28+
29+
use std::fmt::Write as _;
30+
use std::sync::Arc;
31+
32+
use arrow::array::{
33+
Array, ArrayRef, AsArray, GenericListArray, LargeStringArray, OffsetSizeTrait,
34+
StringArray, StringBuilder, StringViewArray,
35+
};
36+
use arrow::datatypes::{DataType, Field};
37+
use datafusion_common::{Result, ScalarValue};
38+
use datafusion_expr::{
39+
ColumnarValue, ScalarFunctionArgs, ScalarUDFImpl, Signature, Volatility,
40+
};
41+
42+
use crate::function::error_utils::{
43+
invalid_arg_count_exec_err, unsupported_data_type_exec_err,
44+
};
45+
46+
#[derive(Debug, PartialEq, Eq, Hash)]
47+
pub struct SparkConcatWs {
48+
signature: Signature,
49+
}
50+
51+
impl Default for SparkConcatWs {
52+
fn default() -> Self {
53+
Self::new()
54+
}
55+
}
56+
57+
impl SparkConcatWs {
58+
pub fn new() -> Self {
59+
Self {
60+
signature: Signature::user_defined(Volatility::Immutable),
61+
}
62+
}
63+
}
64+
65+
impl ScalarUDFImpl for SparkConcatWs {
66+
fn name(&self) -> &str {
67+
"concat_ws"
68+
}
69+
70+
fn signature(&self) -> &Signature {
71+
&self.signature
72+
}
73+
74+
fn return_type(&self, _arg_types: &[DataType]) -> Result<DataType> {
75+
Ok(DataType::Utf8)
76+
}
77+
78+
fn coerce_types(&self, arg_types: &[DataType]) -> Result<Vec<DataType>> {
79+
if arg_types.is_empty() {
80+
return Err(invalid_arg_count_exec_err("concat_ws", (1, i32::MAX), 0));
81+
}
82+
Ok(arg_types
83+
.iter()
84+
.enumerate()
85+
.map(|(i, dt)| match dt {
86+
DataType::Utf8 | DataType::LargeUtf8 | DataType::Utf8View => dt.clone(),
87+
// Non-separator list args expand their elements at runtime.
88+
// Normalize the list variant so the kernel only sees
89+
// List/LargeList, AND force the element type to Utf8 so the
90+
// planner inserts a cast for non-string children (Spark
91+
// coerces them to STRING the same way it does for scalars).
92+
DataType::List(f)
93+
| DataType::ListView(f)
94+
| DataType::FixedSizeList(f, _)
95+
if i > 0 =>
96+
{
97+
DataType::List(Arc::new(Field::new(
98+
f.name(),
99+
DataType::Utf8,
100+
f.is_nullable(),
101+
)))
102+
}
103+
DataType::LargeList(f) | DataType::LargeListView(f) if i > 0 => {
104+
DataType::LargeList(Arc::new(Field::new(
105+
f.name(),
106+
DataType::Utf8,
107+
f.is_nullable(),
108+
)))
109+
}
110+
// Spark casts everything else (numbers, booleans, dates,
111+
// binary, null...) to STRING.
112+
_ => DataType::Utf8,
113+
})
114+
.collect())
115+
}
116+
117+
fn invoke_with_args(&self, args: ScalarFunctionArgs) -> Result<ColumnarValue> {
118+
// Only separator provided → empty string (or NULL if separator is null).
119+
// Arg-count validation happens in coerce_types at planning time.
120+
if args.args.len() == 1 {
121+
return only_separator(&args.args[0]);
122+
}
123+
124+
spark_concat_ws(&args.args, args.number_rows)
125+
}
126+
}
127+
128+
fn only_separator(sep: &ColumnarValue) -> Result<ColumnarValue> {
129+
match sep {
130+
ColumnarValue::Scalar(s) if s.is_null() => {
131+
Ok(ColumnarValue::Scalar(ScalarValue::Utf8(None)))
132+
}
133+
ColumnarValue::Scalar(_) => Ok(ColumnarValue::Scalar(ScalarValue::Utf8(Some(
134+
String::new(),
135+
)))),
136+
ColumnarValue::Array(arr) => {
137+
let mut builder = StringBuilder::with_capacity(arr.len(), 0);
138+
for row_idx in 0..arr.len() {
139+
if arr.is_null(row_idx) {
140+
builder.append_null();
141+
} else {
142+
builder.append_value("");
143+
}
144+
}
145+
Ok(ColumnarValue::Array(Arc::new(builder.finish()) as ArrayRef))
146+
}
147+
}
148+
}
149+
150+
fn spark_concat_ws(args: &[ColumnarValue], num_rows: usize) -> Result<ColumnarValue> {
151+
let arrays = ColumnarValue::values_to_arrays(args)?;
152+
let sep_view = StringView::try_new(&arrays[0])?;
153+
let arg_views: Vec<ArgView> = arrays[1..]
154+
.iter()
155+
.map(ArgView::try_new)
156+
.collect::<Result<_>>()?;
157+
158+
let mut builder = StringBuilder::with_capacity(num_rows, num_rows * 16);
159+
160+
for row_idx in 0..num_rows {
161+
if sep_view.is_null(row_idx) {
162+
builder.append_null();
163+
continue;
164+
}
165+
166+
// Write parts directly into the builder via its `fmt::Write` impl;
167+
// `append_value("")` then finalises the row (offset + validity) with
168+
// no extra copy from an intermediate `String`.
169+
let separator = sep_view.value(row_idx);
170+
let mut first = true;
171+
for view in &arg_views {
172+
view.write_row(row_idx, separator, &mut builder, &mut first)?;
173+
}
174+
builder.append_value("");
175+
}
176+
177+
Ok(ColumnarValue::Array(Arc::new(builder.finish()) as ArrayRef))
178+
}
179+
180+
/// Typed view over a string array that downcasts once and exposes
181+
/// per-row access without further dispatch.
182+
enum StringView<'a> {
183+
Utf8(&'a StringArray),
184+
LargeUtf8(&'a LargeStringArray),
185+
Utf8View(&'a StringViewArray),
186+
}
187+
188+
impl<'a> StringView<'a> {
189+
fn try_new(arr: &'a ArrayRef) -> Result<Self> {
190+
match arr.data_type() {
191+
DataType::Utf8 => Ok(Self::Utf8(arr.as_string::<i32>())),
192+
DataType::LargeUtf8 => Ok(Self::LargeUtf8(arr.as_string::<i64>())),
193+
DataType::Utf8View => Ok(Self::Utf8View(arr.as_string_view())),
194+
other => Err(unsupported_data_type_exec_err("concat_ws", "STRING", other)),
195+
}
196+
}
197+
198+
fn value(&self, idx: usize) -> &str {
199+
match self {
200+
Self::Utf8(a) => a.value(idx),
201+
Self::LargeUtf8(a) => a.value(idx),
202+
Self::Utf8View(a) => a.value(idx),
203+
}
204+
}
205+
206+
fn is_null(&self, idx: usize) -> bool {
207+
match self {
208+
Self::Utf8(a) => a.is_null(idx),
209+
Self::LargeUtf8(a) => a.is_null(idx),
210+
Self::Utf8View(a) => a.is_null(idx),
211+
}
212+
}
213+
}
214+
215+
/// Per-argument view: a string array or a list of strings. The downcast
216+
/// happens once at construction time. `DataType::Null` cannot appear here —
217+
/// `coerce_types` rewrites it to `Utf8` before invocation.
218+
enum ArgView<'a> {
219+
Str(StringView<'a>),
220+
List(&'a GenericListArray<i32>),
221+
LargeList(&'a GenericListArray<i64>),
222+
}
223+
224+
impl<'a> ArgView<'a> {
225+
fn try_new(arr: &'a ArrayRef) -> Result<Self> {
226+
match arr.data_type() {
227+
DataType::Utf8 | DataType::LargeUtf8 | DataType::Utf8View => {
228+
Ok(Self::Str(StringView::try_new(arr)?))
229+
}
230+
DataType::List(_) => Ok(Self::List(arr.as_list::<i32>())),
231+
DataType::LargeList(_) => Ok(Self::LargeList(arr.as_list::<i64>())),
232+
other => Err(unsupported_data_type_exec_err(
233+
"concat_ws",
234+
"STRING or ARRAY<STRING>",
235+
other,
236+
)),
237+
}
238+
}
239+
240+
fn write_row(
241+
&self,
242+
row_idx: usize,
243+
sep: &str,
244+
builder: &mut StringBuilder,
245+
first: &mut bool,
246+
) -> Result<()> {
247+
match self {
248+
Self::Str(view) => {
249+
if !view.is_null(row_idx) {
250+
push_part(builder, view.value(row_idx), sep, first);
251+
}
252+
}
253+
Self::List(list) => write_list_row(*list, row_idx, sep, builder, first)?,
254+
Self::LargeList(list) => write_list_row(*list, row_idx, sep, builder, first)?,
255+
}
256+
Ok(())
257+
}
258+
}
259+
260+
fn write_list_row<O: OffsetSizeTrait>(
261+
list: &GenericListArray<O>,
262+
row_idx: usize,
263+
sep: &str,
264+
builder: &mut StringBuilder,
265+
first: &mut bool,
266+
) -> Result<()> {
267+
if list.is_null(row_idx) {
268+
return Ok(());
269+
}
270+
let values = list.value(row_idx);
271+
// An empty array (e.g. `array()`) contributes nothing — Spark renders it
272+
// as the empty string, not an error.
273+
if values.is_empty() {
274+
return Ok(());
275+
}
276+
let view = StringView::try_new(&values)?;
277+
for i in 0..values.len() {
278+
if !view.is_null(i) {
279+
push_part(builder, view.value(i), sep, first);
280+
}
281+
}
282+
Ok(())
283+
}
284+
285+
// `StringBuilder::write_str` only does `extend_from_slice` and never errors;
286+
// the `.expect(..)` is a documentation hint, not a real failure path.
287+
fn push_part(builder: &mut StringBuilder, part: &str, sep: &str, first: &mut bool) {
288+
if !*first {
289+
builder
290+
.write_str(sep)
291+
.expect("StringBuilder::write_str is infallible");
292+
}
293+
*first = false;
294+
builder
295+
.write_str(part)
296+
.expect("StringBuilder::write_str is infallible");
297+
}

datafusion/spark/src/function/string/mod.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ pub mod ascii;
1919
pub mod base64;
2020
pub mod char;
2121
pub mod concat;
22+
pub mod concat_ws;
2223
pub mod elt;
2324
pub mod format_string;
2425
pub mod ilike;
@@ -40,6 +41,7 @@ make_udf_function!(ascii::SparkAscii, ascii);
4041
make_udf_function!(base64::SparkBase64, base64);
4142
make_udf_function!(char::CharFunc, char);
4243
make_udf_function!(concat::SparkConcat, concat);
44+
make_udf_function!(concat_ws::SparkConcatWs, concat_ws);
4345
make_udf_function!(ilike::SparkILike, ilike);
4446
make_udf_function!(length::SparkLengthFunc, length);
4547
make_udf_function!(elt::SparkElt, elt);
@@ -77,6 +79,11 @@ pub mod expr_fn {
7779
"Concatenates multiple input strings into a single string. Returns NULL if any input is NULL.",
7880
args
7981
));
82+
export_functions!((
83+
concat_ws,
84+
"Concatenates strings with separator. Supports arrays. Null values are skipped.",
85+
sep args
86+
));
8087
export_functions!((
8188
elt,
8289
"Returns the n-th input (1-indexed), e.g. returns 2nd input when n is 2. The function returns NULL if the index is 0 or exceeds the length of the array.",
@@ -142,6 +149,7 @@ pub fn functions() -> Vec<Arc<ScalarUDF>> {
142149
base64(),
143150
char(),
144151
concat(),
152+
concat_ws(),
145153
elt(),
146154
ilike(),
147155
length(),

0 commit comments

Comments
 (0)