This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/datafusion.git
The following commit(s) were added to refs/heads/main by this push:
new f248f451bc fix: handle empty patterns in regexp_instr (#24054)
f248f451bc is described below
commit f248f451bcc02ca5d99502e0bcf1fa3ee20ca448
Author: Haseeb Nazir <[email protected]>
AuthorDate: Tue Aug 4 05:50:23 2026 +0500
fix: handle empty patterns in regexp_instr (#24054)
## Which issue does this PR close?
- Closes #22257.
## Rationale for this change
PostgreSQL treats empty and other zero-width regular-expression matches
as valid character-boundary matches. Before this change, `regexp_instr`
returned 0 before compiling an empty pattern, excluded the terminal
boundary from its start-position mapping, and returned 0 instead of
propagating NULL when the input was NULL and the pattern was empty.
## What changes are included in this PR?
- Let the regex implementation evaluate empty patterns instead of
returning early.
- Map the 1-based start position to UTF-8 byte boundaries including the
terminal boundary.
- Preserve NULL propagation when the input is NULL and the pattern is
empty.
- Add unit coverage for empty patterns and potentially zero-width
patterns across Utf8, LargeUtf8, and Utf8View arrays.
- Add SQL logic coverage for terminal zero-width matches and
empty-pattern NULL input.
## Are these changes tested?
Yes. The following checks pass on the current head:
- `cargo fmt --all -- --check`
- `cargo test -p datafusion-functions
regexpinstr::tests::test_regexp_instr`
- `cargo test --profile=ci --test sqllogictests -- regexp_instr.slt`
- `cargo clippy -p datafusion-functions --tests -- -D warnings`
The implementation commit was also verified with the
repository-prescribed extended workspace suite and `cargo test --profile
ci -p datafusion-cli`; the review follow-up changes add tests only.
## Are there any user-facing changes?
Yes. `regexp_instr` now returns PostgreSQL-compatible positions for
empty and potentially zero-width patterns, including at the terminal
character boundary, and returns NULL for NULL input with an empty
pattern. This does not change the public API.
---
datafusion/functions/src/regex/regexpinstr.rs | 90 ++++++++++++++++++----
.../test_files/regexp/regexp_instr.slt | 12 +++
2 files changed, 89 insertions(+), 13 deletions(-)
diff --git a/datafusion/functions/src/regex/regexpinstr.rs
b/datafusion/functions/src/regex/regexpinstr.rs
index 9d62fe2ffb..7bbc4c4602 100644
--- a/datafusion/functions/src/regex/regexpinstr.rs
+++ b/datafusion/functions/src/regex/regexpinstr.rs
@@ -302,20 +302,12 @@ where
continue;
}
let regex = regex_array.value(i);
- if regex.is_empty() {
- result.append_value(0);
- continue;
- }
if values.is_null(i) {
result.append_null();
continue;
}
let value = values.value(i);
- if value.is_empty() {
- result.append_value(0);
- continue;
- }
let flags = match flags_array {
Some(flags) if !flags.is_null(i) => Some(flags.value(i)),
@@ -376,7 +368,7 @@ impl<'a> RegexCache<'a> {
/// Returns the 1-based character position of the `n`-th match of `pattern` in
/// `value`, or 0 if there is no such match. The search begins at the 1-based
/// character position `start`. A positive `subexpr` selects that capture group
-/// of the first match instead of the `n`-th match. `value` is non-empty.
+/// of the first match instead of the `n`-th match.
fn get_index(
value: &str,
pattern: &Regex,
@@ -396,9 +388,16 @@ fn get_index(
));
}
- // Byte offset of the `start`-th character. A `start` past the end of the
- // string leaves nothing to search, so no match is possible.
- let Some((byte_start_offset, _)) = value.char_indices().nth((start - 1) as
usize)
+ let Ok(start_index) = usize::try_from(start - 1) else {
+ return Ok(0);
+ };
+ // Include the terminal byte boundary so an empty pattern can match after
+ // the last character, including in an empty string.
+ let Some(byte_start_offset) = value
+ .char_indices()
+ .map(|(offset, _)| offset)
+ .chain(std::iter::once(value.len()))
+ .nth(start_index)
else {
return Ok(0);
};
@@ -451,6 +450,14 @@ mod tests {
test_case_sensitive_regexp_instr_array_nth::<GenericStringArray<i32>>();
test_case_sensitive_regexp_instr_array_nth::<GenericStringArray<i64>>();
test_case_sensitive_regexp_instr_array_nth::<StringViewArray>();
+
+
test_case_sensitive_regexp_instr_empty_pattern::<GenericStringArray<i32>>();
+
test_case_sensitive_regexp_instr_empty_pattern::<GenericStringArray<i64>>();
+ test_case_sensitive_regexp_instr_empty_pattern::<StringViewArray>();
+
+
test_case_sensitive_regexp_instr_zero_width_pattern::<GenericStringArray<i32>>();
+
test_case_sensitive_regexp_instr_zero_width_pattern::<GenericStringArray<i64>>();
+
test_case_sensitive_regexp_instr_zero_width_pattern::<StringViewArray>();
}
fn regexp_instr_with_scalar_values(args: &[ScalarValue]) ->
Result<ColumnarValue> {
@@ -479,7 +486,7 @@ mod tests {
fn test_case_sensitive_regexp_instr_nulls() {
let v = "";
let r = "";
- let expected = 0;
+ let expected = 1;
let regex_sv = ScalarValue::Utf8(Some(r.to_string()));
let re = regexp_instr_with_scalar_values(&[v.to_string().into(),
regex_sv]);
// let res_exp = re.unwrap();
@@ -489,6 +496,29 @@ mod tests {
}
_ => panic!("Unexpected result"),
}
+
+ for (value, regex) in [
+ (
+ ScalarValue::Utf8(None),
+ ScalarValue::Utf8(Some(String::new())),
+ ),
+ (
+ ScalarValue::LargeUtf8(None),
+ ScalarValue::LargeUtf8(Some(String::new())),
+ ),
+ (
+ ScalarValue::Utf8View(None),
+ ScalarValue::Utf8View(Some(String::new())),
+ ),
+ ] {
+ let re = regexp_instr_with_scalar_values(&[value, regex]);
+ match re {
+ Ok(ColumnarValue::Scalar(ScalarValue::Int64(v))) => {
+ assert_eq!(v, None, "regexp_instr NULL scalar test
failed");
+ }
+ _ => panic!("Unexpected result"),
+ }
+ }
}
fn test_case_sensitive_regexp_instr_scalar() {
let values = [
@@ -800,4 +830,38 @@ mod tests {
.unwrap();
assert_eq!(re.as_ref(), &expected);
}
+
+ fn test_case_sensitive_regexp_instr_empty_pattern<A>()
+ where
+ A: From<Vec<&'static str>> + Array + 'static,
+ {
+ let values = A::from(vec!["abc", "", "abc", "abc", "😀"]);
+ let regex = A::from(vec!["", "", "", "", ""]);
+ let start = Int64Array::from(vec![1, 1, 4, 5, 1]);
+ let nth = Int64Array::from(vec![1, 1, 1, 1, 2]);
+ let expected = Int64Array::from(vec![1, 1, 4, 0, 2]);
+
+ let re = regexp_instr_func(&[
+ Arc::new(values),
+ Arc::new(regex),
+ Arc::new(start),
+ Arc::new(nth),
+ ])
+ .unwrap();
+ assert_eq!(re.as_ref(), &expected);
+ }
+
+ fn test_case_sensitive_regexp_instr_zero_width_pattern<A>()
+ where
+ A: From<Vec<&'static str>> + Array + 'static,
+ {
+ let values = A::from(vec!["abc"]);
+ let regex = A::from(vec!["x*"]);
+ let start = Int64Array::from(vec![4]);
+ let expected = Int64Array::from(vec![4]);
+
+ let re = regexp_instr_func(&[Arc::new(values), Arc::new(regex),
Arc::new(start)])
+ .unwrap();
+ assert_eq!(re.as_ref(), &expected);
+ }
}
diff --git a/datafusion/sqllogictest/test_files/regexp/regexp_instr.slt
b/datafusion/sqllogictest/test_files/regexp/regexp_instr.slt
index 4182641f19..f54d4e80cc 100644
--- a/datafusion/sqllogictest/test_files/regexp/regexp_instr.slt
+++ b/datafusion/sqllogictest/test_files/regexp/regexp_instr.slt
@@ -23,6 +23,18 @@ SELECT regexp_instr('123123123123123', '(12)3');
----
1
+query IIIIIII
+SELECT
+ regexp_instr('abc', ''),
+ regexp_instr('', ''),
+ regexp_instr('abc', '', 4),
+ regexp_instr('abc', '', 5),
+ regexp_instr('😀', '', 1, 2),
+ regexp_instr('abc', 'x*', 4),
+ regexp_instr(NULL, '');
+----
+1 1 4 0 2 4 NULL
+
query I
SELECT regexp_instr('123123123123', '123', 1);
----
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]