Skip to content
9 changes: 6 additions & 3 deletions cpp/src/strings/regex/regex.inl
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: Copyright (c) 2019-2026, NVIDIA CORPORATION. All rights reserved.
* SPDX-FileCopyrightText: Copyright (c) 2019-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
* SPDX-License-Identifier: Apache-2.0
*/

Expand Down Expand Up @@ -468,8 +468,11 @@ __device__ __forceinline__ match_result reprog_device::extract(int32_t const thr
cudf::size_type end,
cudf::size_type const group_id) const
{
end = begin.position() + 1;
return call_regexec(thread_idx, dstr, begin, end, group_id + 1);
end = begin.position() + 1;
auto const result = call_regexec(thread_idx, dstr, begin, end, group_id + 1);
// a capture group that did not participate in the overall match
// (e.g. an unmatched optional group) has an invalid range
return (result && (result->first >= 0)) ? result : cuda::std::nullopt;
}

template <positional P>
Expand Down
29 changes: 28 additions & 1 deletion cpp/tests/strings/extract_tests.cpp
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: Copyright (c) 2019-2026, NVIDIA CORPORATION.
* SPDX-FileCopyrightText: Copyright (c) 2019-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
* SPDX-License-Identifier: Apache-2.0
*/

Expand Down Expand Up @@ -266,6 +266,33 @@ TEST_F(StringsExtractTests, EmptyExtractTest)
CUDF_TEST_EXPECT_TABLES_EQUAL(*results, table_expected);
}

TEST_F(StringsExtractTests, NonParticipatingGroup)
{
auto input = cudf::test::strings_column_wrapper({"A1", "B2", "C"});
auto sv = cudf::strings_column_view(input);

// the optional group does not participate in the match for "C" and must
// result in null, not an empty string
auto prog = cudf::strings::regex_program::create("(\\D)(\\d)?");
auto results = cudf::strings::extract(sv, *prog);

std::vector<std::unique_ptr<cudf::column>> columns;
columns.push_back(cudf::test::strings_column_wrapper({"A", "B", "C"}).release());
columns.push_back(cudf::test::strings_column_wrapper({"1", "2", ""}, {1, 1, 0}).release());
auto expected = cudf::table(std::move(columns));
CUDF_TEST_EXPECT_TABLES_EQUAL(*results, expected);

// a group that participates with an empty match remains an empty string
auto prog2 = cudf::strings::regex_program::create("(\\D)(\\d*)");
auto results2 = cudf::strings::extract(sv, *prog2);

std::vector<std::unique_ptr<cudf::column>> columns2;
columns2.push_back(cudf::test::strings_column_wrapper({"A", "B", "C"}).release());
columns2.push_back(cudf::test::strings_column_wrapper({"1", "2", ""}).release());
auto expected2 = cudf::table(std::move(columns2));
CUDF_TEST_EXPECT_TABLES_EQUAL(*results2, expected2);
}

TEST_F(StringsExtractTests, ExtractAllTest)
{
std::vector<char const*> h_input(
Expand Down
13 changes: 13 additions & 0 deletions python/cudf/cudf/tests/series/accessors/test_str.py
Original file line number Diff line number Diff line change
Expand Up @@ -2530,6 +2530,19 @@ def test_string_extract(ps_gs, pat, expand, flags, flags_raise):
assert_eq(expect, got)


@pytest.mark.parametrize("pat", [r"(\D)(\d)?", r"(\D)(\d*)"])
def test_string_extract_nonparticipating_group(pat):
# An optional group that does not participate in the match results in
# null, while a group that participates with an empty match remains "".
s = ["A1", "B2", "C"]
gs = cudf.Series(s)
ps = pd.Series(s)

expect = ps.str.extract(pat, expand=True)
got = gs.str.extract(pat, expand=True)
assert_eq(expect, got)


def test_string_extract_named_groups():
s = ["hello-123", "world-456", "goodbye-789"]
gs = cudf.Series(s)
Expand Down
Loading