This is an automated email from the ASF dual-hosted git repository.
kou pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/arrow.git
The following commit(s) were added to refs/heads/main by this push:
new c75bc9aa9f GH-50988: [C++][Python] Substrait: add mappings for
starts_with, ends_with and match_substring (#50989)
c75bc9aa9f is described below
commit c75bc9aa9f966e083546cb4498b21552e2117d3e
Author: Jonas Dedden <[email protected]>
AuthorDate: Mon Sep 28 09:54:19 2026 +0300
GH-50988: [C++][Python] Substrait: add mappings for starts_with, ends_with
and match_substring (#50989)
### Rationale for this change
`starts_with`, `ends_with` and `match_substring` have no Substrait mapping,
so any expression using them fails to serialize:
```python
import pyarrow as pa
import pyarrow.compute as pc
from pyarrow.substrait import serialize_expressions
schema = pa.schema([pa.field("cat", pa.string())])
serialize_expressions([pc.starts_with(pc.field("cat"), "al")], ["f"],
schema)
# ArrowNotImplementedError: No conversion function exists to convert the
# Arrow function starts_with to a Substrait call
```
Comparisons, `isin` and arithmetic serialize fine, so this is a
per-function gap. It matters for engines that ingest a PyArrow filter through
Substrait, where an unmappable function becomes a hard failure rather than a
fallback.
See #50988.
### What changes are included in this PR?
Map the three kernels onto `starts_with`, `ends_with` and `contains` from
Substrait's `functions_string.yaml`, in both directions.
The signatures do not line up. Substrait passes the pattern as a second
argument, while the Arrow kernels are unary and carry it in
`MatchSubstringOptions`. So:
* encoding lifts `MatchSubstringOptions::pattern` out into a literal
argument
* decoding requires that argument to be a non-null string literal, and
returns `NotImplemented` otherwise
Substrait's `case_sensitivity` option maps onto `ignore_case`.
`CASE_INSENSITIVE_ASCII` has no Arrow equivalent and returns `NotImplemented`.
Both caveats are documented in `docs/source/cpp/acero/substrait.rst`.
### Are these changes tested?
Yes.
`Substrait.StringMatchExpressionSerialization` round-trips all three
functions with and without `ignore_case`.
`Substrait.StringMatchExpressionDeserialization` deserializes hand-written
Substrait JSON to cover the default (no option) case and the two rejection
paths. `test_serializing_string_match_expressions` round-trips each function
through `pyarrow.substrait`.
### Are there any user-facing changes?
Yes. `pc.starts_with`, `pc.ends_with` and `pc.match_substring` can now be
serialized to Substrait and consumed back, and Acero can consume plans using
Substrait's `starts_with`, `ends_with` and `contains`. No existing behaviour
changes: these previously raised.
* GitHub Issue: #50988
Authored-by: Jonas Dedden <[email protected]>
Signed-off-by: Sutou Kouhei <[email protected]>
---
cpp/src/arrow/engine/substrait/extension_set.cc | 99 +++++++++++++++++++++++++
cpp/src/arrow/engine/substrait/serde_test.cc | 72 ++++++++++++++++++
docs/source/cpp/acero/substrait.rst | 6 ++
python/pyarrow/tests/test_substrait.py | 13 ++++
4 files changed, 190 insertions(+)
diff --git a/cpp/src/arrow/engine/substrait/extension_set.cc
b/cpp/src/arrow/engine/substrait/extension_set.cc
index 4fb9b19645..78188dd8d7 100644
--- a/cpp/src/arrow/engine/substrait/extension_set.cc
+++ b/cpp/src/arrow/engine/substrait/extension_set.cc
@@ -26,6 +26,7 @@
#include "arrow/compute/api_scalar.h"
#include "arrow/engine/substrait/options.h"
+#include "arrow/scalar.h"
#include "arrow/type.h"
#include "arrow/type_fwd.h"
#include "arrow/util/checked_cast.h"
@@ -33,6 +34,7 @@
#include "arrow/util/hashing.h"
#include "arrow/util/logging_internal.h"
#include "arrow/util/string.h"
+#include "arrow/util/utf8.h"
namespace arrow {
@@ -757,6 +759,19 @@ static std::vector<std::string> kRoundModes = {
"TIE_UP", "TIE_TOWARDS_ZERO", "TIE_AWAY_FROM_ZERO", "TIE_TO_EVEN",
"TIE_TO_ODD"};
static EnumParser<compute::RoundMode> kRoundModeParser(kRoundModes);
+// The case_sensitivity option used by the Substrait string functions
+// (starts_with / ends_with / contains). Arrow's MatchSubstringOptions only
+// distinguishes case sensitive from case insensitive, so
CASE_INSENSITIVE_ASCII
+// is parsed but not implemented.
+enum class CaseSensitivity {
+ kCaseSensitive = 0,
+ kCaseInsensitive,
+ kCaseInsensitiveAscii
+};
+static std::vector<std::string> kCaseSensitivityOptions = {
+ "CASE_SENSITIVE", "CASE_INSENSITIVE", "CASE_INSENSITIVE_ASCII"};
+static EnumParser<CaseSensitivity>
kCaseSensitivityParser(kCaseSensitivityOptions);
+
template <typename Enum>
Result<Enum> ParseOptionOrElse(const SubstraitCall& call, std::string_view
option_name,
const EnumParser<Enum>& parser,
@@ -961,6 +976,74 @@ ExtensionIdRegistry::SubstraitCallToArrow
DecodeConcatMapping() {
};
}
+// Substrait's starts_with / ends_with / contains take the pattern as a second
+// value argument. The matching Arrow kernels (starts_with / ends_with /
+// match_substring) are unary and carry the pattern in MatchSubstringOptions,
so
+// the second argument must be a string literal.
+ExtensionIdRegistry::SubstraitCallToArrow DecodeMatchSubstringMapping(
+ const std::string& function_name) {
+ return [function_name](const SubstraitCall& call) ->
Result<compute::Expression> {
+ if (call.size() != 2) {
+ return Status::NotImplemented("Acero does not have a kernel for ",
function_name,
+ " that receives ", call.size(), "
arguments");
+ }
+ ARROW_ASSIGN_OR_RAISE(
+ CaseSensitivity case_sensitivity,
+ ParseOptionOrElse(
+ call, "case_sensitivity", kCaseSensitivityParser,
+ {CaseSensitivity::kCaseSensitive,
CaseSensitivity::kCaseInsensitive},
+ CaseSensitivity::kCaseSensitive));
+ ARROW_ASSIGN_OR_RAISE(compute::Expression input, call.GetValueArg(0));
+ ARROW_ASSIGN_OR_RAISE(compute::Expression substring, call.GetValueArg(1));
+ const Datum* pattern = substring.literal();
+ if (pattern == nullptr || !pattern->is_scalar() ||
+ pattern->type()->id() != Type::STRING || !pattern->scalar()->is_valid)
{
+ return Status::NotImplemented(
+ "The Arrow ", function_name,
+ " kernel requires the substring argument to be a non-null string
literal");
+ }
+ auto options = std::make_shared<compute::MatchSubstringOptions>(
+ std::string(pattern->scalar_as<StringScalar>().view()),
+ /*ignore_case=*/case_sensitivity == CaseSensitivity::kCaseInsensitive);
+ return compute::call(function_name, {std::move(input)},
std::move(options));
+ };
+}
+
+ExtensionIdRegistry::ArrowToSubstraitCall EncodeMatchSubstring(Id
substrait_fn_id) {
+ return
+ [substrait_fn_id](const compute::Expression::Call& call) ->
Result<SubstraitCall> {
+ if (call.options == nullptr) {
+ return Status::Invalid("The call to ", call.function_name,
+ " is missing its MatchSubstringOptions");
+ }
+ // Substrait's starts_with / ends_with / contains only accept strings
while
+ // the Arrow kernels also accept binary-like input and non-UTF-8
patterns
+ const DataType* input_type = call.arguments[0].type();
+ if (input_type == nullptr || input_type->id() != Type::STRING) {
+ return Status::NotImplemented(
+ "Substrait only supports ", substrait_fn_id.name,
+ " on string input but the input to ", call.function_name, " is ",
+ input_type == nullptr ? "unbound" : input_type->ToString());
+ }
+ auto match_options =
+ checked_pointer_cast<compute::MatchSubstringOptions>(call.options);
+ util::InitializeUTF8();
+ if (!util::ValidateUTF8(match_options->pattern)) {
+ return Status::NotImplemented("Substrait only supports ",
substrait_fn_id.name,
+ " with a valid UTF-8 pattern");
+ }
+ // nullable=true errs on the side of caution
+ SubstraitCall substrait_call(substrait_fn_id, call.type.GetSharedPtr(),
+ /*nullable=*/true);
+ substrait_call.SetValueArg(0, call.arguments[0]);
+ substrait_call.SetValueArg(1,
compute::literal(match_options->pattern));
+ substrait_call.SetOption(
+ "case_sensitivity",
+ {match_options->ignore_case ? "CASE_INSENSITIVE" :
"CASE_SENSITIVE"});
+ return substrait_call;
+ };
+}
+
ExtensionIdRegistry::SubstraitAggregateToArrow DecodeBasicAggregate(
const std::string& arrow_function_name) {
return [arrow_function_name](const SubstraitCall& call) ->
Result<compute::Aggregate> {
@@ -1147,6 +1230,13 @@ struct DefaultExtensionIdRegistry :
ExtensionIdRegistryImpl {
DecodeTemporalExtractionMapping()));
DCHECK_OK(AddSubstraitCallToArrow({kSubstraitStringFunctionsUri, "concat"},
DecodeConcatMapping()));
+ for (const auto& fn_pair : std::vector<std::pair<std::string_view,
std::string>>{
+ {"starts_with", "starts_with"},
+ {"ends_with", "ends_with"},
+ {"contains", "match_substring"}}) {
+ DCHECK_OK(AddSubstraitCallToArrow({kSubstraitStringFunctionsUri,
fn_pair.first},
+
DecodeMatchSubstringMapping(fn_pair.second)));
+ }
DCHECK_OK(
AddSubstraitCallToArrow({kSubstraitComparisonFunctionsUri, "is_null"},
DecodeOptionlessBasicMapping("is_null",
/*max_args=*/1)));
@@ -1239,6 +1329,15 @@ struct DefaultExtensionIdRegistry :
ExtensionIdRegistryImpl {
DCHECK_OK(AddArrowToSubstraitCall(
"is_null", EncodeIsNull({kSubstraitComparisonFunctionsUri,
"is_null"})));
+
+ for (const auto& fn_pair : std::vector<std::pair<std::string,
std::string_view>>{
+ {"starts_with", "starts_with"},
+ {"ends_with", "ends_with"},
+ {"match_substring", "contains"}}) {
+ DCHECK_OK(AddArrowToSubstraitCall(
+ fn_pair.first,
+ EncodeMatchSubstring({kSubstraitStringFunctionsUri,
fn_pair.second})));
+ }
}
};
diff --git a/cpp/src/arrow/engine/substrait/serde_test.cc
b/cpp/src/arrow/engine/substrait/serde_test.cc
index 4de5480ef3..3118763798 100644
--- a/cpp/src/arrow/engine/substrait/serde_test.cc
+++ b/cpp/src/arrow/engine/substrait/serde_test.cc
@@ -6377,5 +6377,77 @@ TEST(Substrait, ExtendedExpressionInvalidPlans) {
Raises(StatusCode::Invalid, testing::HasSubstr("Ambiguous
plan")));
}
+TEST(Substrait, StringMatchExpressionSerialization) {
+ std::shared_ptr<Schema> test_schema = schema({field("cat", utf8())});
+ for (const auto& fn_name : {"starts_with", "ends_with", "match_substring"}) {
+ for (bool ignore_case : {false, true}) {
+ CheckExpressionRoundTrip(
+ *test_schema, compute::call(fn_name, {compute::field_ref(0)},
+ compute::MatchSubstringOptions("al",
ignore_case)));
+ }
+ }
+
+ // Substrait only accepts string input and UTF-8 patterns
+ auto serialize = [](const std::shared_ptr<DataType>& type,
+ std::string pattern) -> Result<std::shared_ptr<Buffer>> {
+ Schema schema({field("s", type)});
+ ExtensionSet ext_set;
+ ARROW_ASSIGN_OR_RAISE(
+ compute::Expression bound,
+ compute::call("starts_with", {compute::field_ref(0)},
+ compute::MatchSubstringOptions(std::move(pattern)))
+ .Bind(schema));
+ return SerializeExpression(bound, &ext_set);
+ };
+ ASSERT_THAT(serialize(binary(), "al"),
+ Raises(StatusCode::NotImplemented, HasSubstr("on string
input")));
+ ASSERT_THAT(serialize(large_utf8(), "al"),
+ Raises(StatusCode::NotImplemented, HasSubstr("on string
input")));
+ ASSERT_THAT(serialize(utf8(), "\xff"),
+ Raises(StatusCode::NotImplemented, HasSubstr("valid UTF-8
pattern")));
+}
+
+TEST(Substrait, StringMatchExpressionDeserialization) {
+ ExtensionSet ext_set;
+ ASSERT_OK_AND_ASSIGN(
+ uint32_t anchor,
+ ext_set.EncodeFunction({kSubstraitStringFunctionsUri, "starts_with"}));
+ // A starts_with call on field 0 with the given pattern argument and options
+ auto deserialize = [&](std::string_view pattern_arg,
+ std::string_view options) ->
Result<compute::Expression> {
+ std::string json = R"({"scalarFunction":{"functionReference":)" +
+ std::to_string(anchor) +
+ R"(,"outputType":{"bool":{}},"arguments":[
+ {"value":{"selection":{"directReference":{"structField":{"field":0}},
+ "rootReference":{}}}},
+ {"value":)" + std::string(pattern_arg) +
+ R"(}],"options":)" + std::string(options) + "}}";
+ ARROW_ASSIGN_OR_RAISE(std::shared_ptr<Buffer> buf,
+ internal::SubstraitFromJSON("Expression", json));
+ return DeserializeExpression(*buf, ext_set);
+ };
+ constexpr std::string_view kLiteral = R"({"literal":{"string":"al"}})";
+
+ // No case_sensitivity option means case sensitive
+ ASSERT_OK_AND_ASSIGN(compute::Expression no_option, deserialize(kLiteral,
"[]"));
+ ASSERT_EQ(compute::call("starts_with", {compute::field_ref(0)},
+ compute::MatchSubstringOptions("al")),
+ no_option);
+
+ // CASE_INSENSITIVE_ASCII has no Arrow equivalent
+ ASSERT_THAT(
+ deserialize(
+ kLiteral,
+
R"([{"name":"case_sensitivity","preference":["CASE_INSENSITIVE_ASCII"]}])"),
+ Raises(StatusCode::NotImplemented, HasSubstr("the only supported options
are")));
+
+ // The pattern has to be a literal because the Arrow kernel takes it as an
option
+ ASSERT_THAT(
+
deserialize(R"({"selection":{"directReference":{"structField":{"field":0}},
+ "rootReference":{}}})",
+ "[]"),
+ Raises(StatusCode::NotImplemented, HasSubstr("substring argument to be
a")));
+}
+
} // namespace engine
} // namespace arrow
diff --git a/docs/source/cpp/acero/substrait.rst
b/docs/source/cpp/acero/substrait.rst
index a553273362..168cd9b5af 100644
--- a/docs/source/cpp/acero/substrait.rst
+++ b/docs/source/cpp/acero/substrait.rst
@@ -227,6 +227,12 @@ Functions
* Acero does not support the SATURATE option for overflow
* Acero does not support kernels that take more than two arguments
for the functions ``and``, ``or``, ``xor``
+ * The functions ``starts_with``, ``ends_with``, and ``contains`` map onto
Acero
+ kernels that take the substring as a function option. The second argument
+ must therefore be a non-null string literal. The ``case_sensitivity``
option
+ supports ``CASE_SENSITIVE`` and ``CASE_INSENSITIVE`` but not
+ ``CASE_INSENSITIVE_ASCII``. Only calls on ``string`` input with a valid
+ UTF-8 pattern can be serialized to Substrait
* Substrait has not yet clearly identified the form that URIs should take for
standard functions. Acero will look for the URIs to the ``main`` GitHub
branch.
diff --git a/python/pyarrow/tests/test_substrait.py
b/python/pyarrow/tests/test_substrait.py
index fcd1c8d48c..430bbeb652 100644
--- a/python/pyarrow/tests/test_substrait.py
+++ b/python/pyarrow/tests/test_substrait.py
@@ -946,6 +946,19 @@ def test_serializing_expressions(expr):
assert "test_expr" in returned.expressions
[email protected]("func", [pc.starts_with, pc.ends_with,
pc.match_substring])
+def test_serializing_string_match_expressions(func):
+ # GH-50988: these map onto Substrait's starts_with / ends_with / contains
+ schema = pa.schema([pa.field("s", pa.string())])
+ expr = func(pc.field("s"), "al", ignore_case=True)
+
+ buf = pa.substrait.serialize_expressions([expr], ["test_expr"], schema)
+ returned = pa.substrait.deserialize_expressions(buf)
+ # Substrait refers to fields by index, so compare against the normalized
form
+ assert str(returned.expressions["test_expr"]) == str(
+ func(pc.field(0), "al", ignore_case=True))
+
+
def test_arrow_specific_types():
fields = {
"time_seconds": (pa.time32("s"), 0),