Skip to content

[Analytics Backend / DataFusion] Wire PPL replace command + replace()/regexp_replace() functions - #21527

Merged
mch2 merged 1 commit into
opensearch-project:mainfrom
RyanL1997:mustang-replace
May 7, 2026
Merged

[Analytics Backend / DataFusion] Wire PPL replace command + replace()/regexp_replace() functions#21527
mch2 merged 1 commit into
opensearch-project:mainfrom
RyanL1997:mustang-replace

Conversation

@RyanL1997

@RyanL1997 RyanL1997 commented May 7, 2026

Copy link
Copy Markdown
Contributor

Description

Onboards the PPL replace command and the replace() / regexp_replace() eval functions to the analytics-engine route by mapping their two Calcite lowering targets — SqlStdOperatorTable.REPLACE and SqlLibraryOperators.REGEXP_REPLACE_3 — through Substrait to DataFusion's native replace and regexp_replace UDFs.

Same templated shape as the fillnull POC (#21472) and the ABS/SUBSTRING follow-up (#21521):

ScalarFunction enum constant
  + STANDARD_PROJECT_OPS membership
  + opensearch_scalar_functions.yaml extension entry
  + ADDITIONAL_SCALAR_SIGS Calcite-op→Substrait-name bridge
  = onboarded to the analytics route.

Two scalar functions added: REPLACE (literal substring replace) and REGEXP_REPLACE (regex replace). Both project-side only — the comparison result of a replaced field is filtered via the existing EQUALS capability, so no STANDARD_FILTER_OPS additions are needed.

Why an adapter is necessary for REGEXP_REPLACE

PPL's wildcard replace form lowers via the SQL plugin's WildcardUtils.convertWildcardPatternToRegex() to a Java-Pattern-compatible regex. Two flavors of Java syntax need translation before substrait serialization, because DataFusion uses Rust's regex crate which has different parsing rules:

  1. \Q…\E quoted-literal blocks. Rust rejects \Q as an unrecognized escape sequence:

    External error: regex parse error:
        ^\QBUSINESS\E(.*?)\Q\E$
         ^^
    error: unrecognized escape sequence
    

    The adapter expands each \Q…\E block to per-character escaped literals — semantics-preserving, no behavior change.

  2. $N numeric backreferences in the replacement. Rust's replacement parser is identifier-greedy: $1_$2 is parsed as a reference to the (non-existent) group named 1_ followed by group $2, yielding empty + group-2's value. Java's Matcher.replaceAll stops at the first non-digit, so $1_$2 means group-1 + literal underscore + group-2. The adapter wraps every numeric backreference in braces (${N}) for unambiguous Rust parsing.

Both transforms live in RegexpReplaceAdapter and are registered against ScalarFunction.REGEXP_REPLACE in scalarFunctionAdapters(). Calls without \Q in the pattern AND without bare $N in the replacement pass through unchanged.

Test results

Suite Result
RegexpReplaceAdapterTests (unit) 19/19 — unquote: 9, brace: 7, dual-rewrite integration: 3
ReplaceCommandIT (new self-contained QA IT, calcs dataset) 10/10 — literal command (single + multi-pair = nested REPLACE), wildcard prefix/suffix, replace() / regexp_replace() in eval, full-row content checks, no-match passthrough, multi-field IN clause
SQL plugin's CalciteReplaceCommandIT force-routed via -Dtests.analytics.{force_routing,parquet_indices}=true 21/21 in both the direct suite and the CalciteNoPushdownIT re-run

./gradlew check -p sandbox -Dsandbox.enabled=true is green except for the unrelated upstream ScalarDateTimeFunctionIT > testConvertTz flake (added by bd8e81027a6 from #21476).

Companion PR

opensearch-project/sql#5415 — makes CalciteReplaceCommandIT column-order-agnostic for the analytics-engine route, mirroring the rename precedent from #5413. Required for the SQL-plugin-side 21/21 result above.

Check List

  • New functionality includes testing.
  • All tests pass (excluding the unrelated upstream testConvertTz flake).
  • New functionality has been documented (this PR description; class javadoc on RegexpReplaceAdapter documents the Java↔Rust regex divergence).
  • API changes companion pull request created — N/A.
  • Public documentation issue/PR created — N/A.

@github-actions

github-actions Bot commented May 7, 2026

Copy link
Copy Markdown
Contributor

PR Reviewer Guide 🔍

(Review updated until commit 2f880e7)

Here are some key observations to aid the review process:

🧪 PR contains tests
🔒 No security concerns identified
✅ No TODO sections
🔀 Multiple PR themes

Sub-PR theme: Wire REPLACE and REGEXP_REPLACE scalar functions through Substrait

Relevant files:

  • sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/ScalarFunction.java
  • sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/DataFusionAnalyticsBackendPlugin.java
  • sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/DataFusionFragmentConvertor.java
  • sandbox/plugins/analytics-backend-datafusion/src/main/resources/opensearch_scalar_functions.yaml

Sub-PR theme: Add RegexpReplaceAdapter for Java-to-Rust regex syntax normalization

Relevant files:

  • sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/RegexpReplaceAdapter.java
  • sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/RegexpReplaceAdapterTests.java

Sub-PR theme: Add integration tests for PPL replace command and replace/regexp_replace functions

Relevant files:

  • sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/ReplaceCommandIT.java

⚡ Recommended focus areas for review

Duplicate Entry

The ADDITIONAL_SCALAR_SIGS list contains a duplicate entry for DelegatedPredicateFunction.FUNCTION mapped to DelegatedPredicateFunction.NAME. This was present before the PR and remains in the new code. While not introduced by this PR, the new entries were added alongside it and the list should be reviewed for correctness.

private static final List<FunctionMappings.Sig> ADDITIONAL_SCALAR_SIGS = List.of(
    FunctionMappings.s(DelegatedPredicateFunction.FUNCTION, DelegatedPredicateFunction.NAME),
    FunctionMappings.s(SqlLibraryOperators.ILIKE, "ilike"),
    FunctionMappings.s(DelegatedPredicateFunction.FUNCTION, DelegatedPredicateFunction.NAME),
    FunctionMappings.s(SqlLibraryOperators.DATE_PART, "date_part"),
    FunctionMappings.s(ConvertTzAdapter.LOCAL_CONVERT_TZ_OP, "convert_tz"),
    FunctionMappings.s(UnixTimestampAdapter.LOCAL_TO_UNIXTIME_OP, "to_unixtime"),
    FunctionMappings.s(SqlLibraryOperators.REGEXP_CONTAINS, "regex_match"),
    FunctionMappings.s(SqlStdOperatorTable.REPLACE, "replace"),
    FunctionMappings.s(SqlLibraryOperators.REGEXP_REPLACE_3, "regexp_replace")
);
Non-Literal Replacement

When the replacement operand is a non-literal (e.g., a column reference), the $N backreference brace-wrapping is silently skipped. This is documented as a pass-through, but there is no warning or comment explaining that runtime failures may occur if a column value contains bare $N patterns in a Rust regex context. Consider at least logging a warning.

String rewrittenReplacement = null;
if (replacementOperand instanceof RexLiteral replacementLiteral) {
    String replacement = replacementLiteral.getValueAs(String.class);
    if (replacement != null && replacement.indexOf('$') >= 0) {
        String rewritten = braceBackreferences(replacement);
        if (!replacement.equals(rewritten)) {
            rewrittenReplacement = rewritten;
        }
    }
}
Static State Risk

The dataProvisioned static boolean field is not thread-safe and could cause issues if tests are run in parallel. Additionally, using a static flag for test setup is fragile — if a previous test class leaves the index in a bad state, dataProvisioned will remain true and the provisioning step will be skipped. Consider using @BeforeClass or the framework's built-in provisioning mechanism instead.

private static boolean dataProvisioned = false;

private void ensureDataProvisioned() throws IOException {
    if (dataProvisioned == false) {
        DatasetProvisioner.provision(client(), DATASET);
        dataProvisioned = true;
    }
}
Unused Method

The assertErrorContains helper method is defined but never called in any test. This dead code should either be used in a negative test case (e.g., testing an invalid regex pattern) or removed.

private void assertErrorContains(String ppl, String expectedSubstring) {
    try {
        Map<String, Object> response = executePpl(ppl);
        fail("Expected query to fail with [" + expectedSubstring + "] but got response: " + response);
    } catch (ResponseException e) {
        String body;
        try {
            body = org.opensearch.test.rest.OpenSearchRestTestCase.entityAsMap(e.getResponse()).toString();
        } catch (IOException ioe) {
            body = e.getMessage();
        }
        assertTrue(
            "Expected response body to contain [" + expectedSubstring + "] but was: " + body,
            body.contains(expectedSubstring)
        );
    } catch (IOException e) {
        fail("Unexpected IOException: " + e);
    }
}
Metachar Escaping

The REGEX_METACHARS string includes / as a metacharacter to escape inside \Q…\E blocks. However, / is not a regex metacharacter in Java's Pattern or Rust's regex crate — it has no special meaning and does not need escaping. This could produce unnecessarily escaped output like \/ which, while harmless in most engines, may be unexpected.

private static final String REGEX_METACHARS = ".\\+*?^$()[]{}|/";

@github-actions

github-actions Bot commented May 7, 2026

Copy link
Copy Markdown
Contributor

PR Code Suggestions ✨

Latest suggestions up to 2f880e7
Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
Possible issue
Remove duplicate function mapping entry

There is a duplicate entry for DelegatedPredicateFunction.FUNCTION in
ADDITIONAL_SCALAR_SIGS. The same function/name pair appears twice, which is
redundant and may cause unexpected behavior in function resolution. Remove the
duplicate entry.

sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/DataFusionFragmentConvertor.java [101-103]

 FunctionMappings.s(DelegatedPredicateFunction.FUNCTION, DelegatedPredicateFunction.NAME),
     FunctionMappings.s(SqlLibraryOperators.ILIKE, "ilike"),
-    FunctionMappings.s(DelegatedPredicateFunction.FUNCTION, DelegatedPredicateFunction.NAME),
Suggestion importance[1-10]: 7

__

Why: The duplicate DelegatedPredicateFunction.FUNCTION entry at line 103 is a real bug that existed before this PR (visible in both old and new hunks). While this PR didn't introduce it, removing it would prevent potential issues with function resolution. The suggestion correctly identifies the redundancy and the improved_code accurately reflects the fix.

Medium
General
Fix thread-safety of static provisioning flag

The dataProvisioned static flag is not thread-safe. If tests run in parallel,
multiple threads could simultaneously see dataProvisioned == false and call
DatasetProvisioner.provision() concurrently, potentially causing race conditions or
duplicate provisioning. Use a @BeforeClass-equivalent mechanism or synchronize
access to this flag.

sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/ReplaceCommandIT.java [52-59]

-private static boolean dataProvisioned = false;
+private static volatile boolean dataProvisioned = false;
 
-private void ensureDataProvisioned() throws IOException {
+private synchronized void ensureDataProvisioned() throws IOException {
     if (dataProvisioned == false) {
         DatasetProvisioner.provision(client(), DATASET);
         dataProvisioned = true;
     }
 }
Suggestion importance[1-10]: 4

__

Why: Adding volatile and synchronized to the provisioning mechanism is a valid thread-safety improvement. However, in typical OpenSearch test frameworks, tests usually run sequentially within a class, making this a low-priority concern in practice.

Low
Document greedy identifier matching boundary behavior

The braceBackreferences method only wraps numeric backreferences ($1, $12), but
Rust's regex crate also supports named backreferences like $name. However, the more
critical issue is that the digit-scanning loop starts at i + 1 (the position of
next, which is already confirmed to be a digit), but then the loop condition
re-checks charAt(j) starting at i + 1 — this is correct. However, the loop should
also stop at non-digit characters that are valid Rust identifier chars (letters,
underscores) to ensure the brace boundary is placed correctly. Currently, $1abc
would produce ${1}abc which is correct, but $1_ would produce ${1}_ which is also
correct. The logic is fine for digits-only group names, but if the intent is to
handle all cases where Rust would greedily extend the group name (including letters
and underscores after digits), the loop should consume all identifier characters,
not just digits.

sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/RegexpReplaceAdapter.java [140-146]

 if (Character.isDigit(next)) {
                 // Bare $N — wrap in braces so Rust doesn't consume following identifier
                 // characters (letters, digits, underscores) as part of the group name.
                 int j = i + 1;
                 while (j < replacement.length() && Character.isDigit(replacement.charAt(j))) {
                     j++;
                 }
+                // After digits, Rust also greedily consumes letters and underscores as part
+                // of the group name. Wrap only the digit portion so the boundary is explicit.
Suggestion importance[1-10]: 2

__

Why: The suggestion acknowledges the existing logic is correct for the intended use case (numeric group names only), and the improved_code only adds a comment without changing behavior. This is essentially a documentation-only suggestion with minimal impact.

Low

Previous suggestions

Suggestions up to commit 4083b7e
CategorySuggestion                                                                                                                                    Impact
Possible issue
Remove duplicate function mapping entry

DelegatedPredicateFunction.FUNCTION is registered twice in ADDITIONAL_SCALAR_SIGS.
The duplicate entry (line 4 of the list) is likely a copy-paste error and may cause
unexpected behavior or conflicts during Substrait serialization. Remove the
duplicate entry.

sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/DataFusionFragmentConvertor.java [96-105]

 private static final List<FunctionMappings.Sig> ADDITIONAL_SCALAR_SIGS = List.of(
     FunctionMappings.s(DelegatedPredicateFunction.FUNCTION, DelegatedPredicateFunction.NAME),
     FunctionMappings.s(SqlLibraryOperators.ILIKE, "ilike"),
-    FunctionMappings.s(DelegatedPredicateFunction.FUNCTION, DelegatedPredicateFunction.NAME),
     FunctionMappings.s(SqlLibraryOperators.DATE_PART, "date_part"),
     FunctionMappings.s(ConvertTzAdapter.LOCAL_CONVERT_TZ_OP, "convert_tz"),
     FunctionMappings.s(UnixTimestampAdapter.LOCAL_TO_UNIXTIME_OP, "to_unixtime"),
     FunctionMappings.s(SqlStdOperatorTable.REPLACE, "replace"),
     FunctionMappings.s(SqlLibraryOperators.REGEXP_REPLACE_3, "regexp_replace")
 );
Suggestion importance[1-10]: 7

__

Why: The duplicate DelegatedPredicateFunction.FUNCTION entry at line 99 is a pre-existing issue (visible in both old and new hunks), but it's a real bug that could cause conflicts during Substrait serialization. The improved code correctly removes the duplicate while preserving all other entries.

Medium
General
Fix thread-safety of static provisioning flag

The dataProvisioned static flag is not thread-safe. If tests run in parallel,
multiple threads could simultaneously pass the if check and call
DatasetProvisioner.provision() more than once, potentially causing race conditions
or duplicate provisioning errors. Use a @BeforeClass / @AfterClass pattern or
synchronize access to the flag.

sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/ReplaceCommandIT.java [52-59]

-private static boolean dataProvisioned = false;
+private static volatile boolean dataProvisioned = false;
 
-private void ensureDataProvisioned() throws IOException {
+private synchronized void ensureDataProvisioned() throws IOException {
     if (dataProvisioned == false) {
         DatasetProvisioner.provision(client(), DATASET);
         dataProvisioned = true;
     }
 }
Suggestion importance[1-10]: 4

__

Why: The thread-safety concern is valid for parallel test execution, but integration tests in OpenSearch typically run sequentially. The volatile + synchronized approach in the improved code is a reasonable fix, though a @BeforeClass pattern would be cleaner.

Low
Fail fast on unexpected operand count

The adapter silently passes through calls with a wrong operand count instead of
signaling an error. Since REGEXP_REPLACE_3 is defined to always have exactly 3
operands, a mismatch indicates a programming error rather than a valid runtime case.
Consider throwing an IllegalArgumentException to surface misuse early.

sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/RegexpReplaceAdapter.java [60-62]

 if (original.getOperands().size() != 3) {
-    return original;
+    throw new IllegalArgumentException(
+        "RegexpReplaceAdapter expects exactly 3 operands but got " + original.getOperands().size()
+    );
 }
Suggestion importance[1-10]: 3

__

Why: While failing fast is generally good practice, the silent pass-through is a reasonable defensive approach for an adapter that may encounter unexpected call shapes. The suggestion is valid but has limited impact since REGEXP_REPLACE_3 is always 3-arity by definition.

Low

@RyanL1997
RyanL1997 marked this pull request as ready for review May 7, 2026 00:53
@RyanL1997
RyanL1997 requested a review from a team as a code owner May 7, 2026 00:53
@github-actions

github-actions Bot commented May 7, 2026

Copy link
Copy Markdown
Contributor

❌ Gradle check result for 4083b7e: FAILURE

Please examine the workflow log, locate, and copy-paste the failure(s) below, then iterate to green. Is the failure a flaky test unrelated to your change?

@RyanL1997

Copy link
Copy Markdown
Contributor Author

The conflicts are caused by the merge of #21524, will resolve it soon.

…/regexp_replace() functions

Onboards the PPL `replace` command and the `replace()` / `regexp_replace()`
eval functions to the analytics-engine route by mapping their two Calcite
lowering targets — `SqlStdOperatorTable.REPLACE` and
`SqlLibraryOperators.REGEXP_REPLACE_3` — through Substrait to DataFusion's
native `replace` and `regexp_replace` UDFs.

Same templated shape as the `fillnull` POC (opensearch-project#21472):

  ScalarFunction enum constant
    + STANDARD_PROJECT_OPS membership
    + opensearch_scalar_functions.yaml extension entry
    + ADDITIONAL_SCALAR_SIGS Calcite-op→Substrait-name bridge
    = onboarded to the analytics route.

Two scalar functions added: REPLACE (literal substring replace) and
REGEXP_REPLACE (regex replace). Both project-side only; the comparison
result of a replaced field is filtered via the existing EQUALS capability,
so no STANDARD_FILTER_OPS additions are needed.

PPL's wildcard `replace` form lowers via `WildcardUtils.convertWildcardPatternToRegex()`
to a Java-`Pattern`-compatible regex. Two flavors of Java syntax need
translation before substrait serialization, because DataFusion uses Rust's
`regex` crate which has different parsing rules:

  * `\Q…\E` quoted-literal blocks — Rust rejects `\Q` as an unrecognized
    escape sequence. The adapter expands each block to per-character
    escaped literals (semantics-preserving).
  * `$N` numeric backreferences in the replacement — Rust's replacement
    parser is identifier-greedy, so `$1_$2` is parsed as a reference to
    group named `1_` followed by `$2` (Java parses it as group 1 + literal
    underscore + group 2). The adapter wraps every numeric backreference
    in braces (`${N}`) for unambiguous Rust parsing.

Both transforms are in `RegexpReplaceAdapter` and registered against
`ScalarFunction.REGEXP_REPLACE` in `scalarFunctionAdapters()`. Calls
without `\Q` in the pattern AND without bare `$N` in the replacement pass
through unchanged.

  * `RegexpReplaceAdapterTests` — 19/19 (unquote: 9, brace: 7, dual-rewrite
    integration: 3).
  * `ReplaceCommandIT` (new self-contained QA IT, calcs dataset) — 10/10.
    Covers literal command (single + multi-pair = nested REPLACE), wildcard
    command (prefix + suffix), `replace()` and `regexp_replace()` in eval,
    full-row content checks, no-match passthrough, multi-field IN clause.
  * SQL plugin's `CalciteReplaceCommandIT` force-routed through the
    analytics-engine route via `-Dtests.analytics.{force_routing,parquet_indices}=true`
    — 21/21 in both the direct suite and the `CalciteNoPushdownIT` re-run.
    (Companion SQL plugin PR opensearch-project#5415 makes 4 column-order assertions and 1
    error-message assertion order-agnostic, mirroring the rename precedent
    from opensearch-project#5413.)

Unlike `fillnull`/`regex` where the bridge was a single one-line capability
addition, `replace`'s wildcard form exposes Java↔Rust regex syntax
divergence. The adapter is reusable for any future Calcite operator whose
PPL lowering goes through `WildcardUtils` (e.g. potential future patterns
in `like`-with-escape, custom regex lowerings).

Signed-off-by: Jialiang Liang <jiallian@amazon.com>
@github-actions

github-actions Bot commented May 7, 2026

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 2f880e7

@github-actions

github-actions Bot commented May 7, 2026

Copy link
Copy Markdown
Contributor

✅ Gradle check result for 2f880e7: SUCCESS

@codecov

codecov Bot commented May 7, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 73.49%. Comparing base (6888345) to head (2f880e7).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files
@@             Coverage Diff              @@
##               main   #21527      +/-   ##
============================================
+ Coverage     73.42%   73.49%   +0.07%     
- Complexity    74547    74628      +81     
============================================
  Files          5978     5978              
  Lines        338743   338743              
  Branches      48843    48843              
============================================
+ Hits         248707   248947     +240     
+ Misses        70229    69925     -304     
- Partials      19807    19871      +64     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@mch2
mch2 merged commit 765c4f0 into opensearch-project:main May 7, 2026
16 checks passed
RyanL1997 added a commit to RyanL1997/OpenSearch that referenced this pull request May 7, 2026
…dge-only

Onboards the PPL `rex` command's `mode=sed` surface — the part that lowers to
standard Calcite library operators and bridges through Substrait to DataFusion's
native UDFs. Three sed sub-variants covered:

  * `rex field=f mode=sed "s/old/new/"` (no flags) → SqlLibraryOperators.REGEXP_REPLACE_3
    (already mapped via the PPL `replace` onboarding from opensearch-project#21527 — no-op here).

  * `rex field=f mode=sed "s/old/new/g"` / `/i` / `/gi` → SqlLibraryOperators.REGEXP_REPLACE_PG_4
    (4-arg with flags string). New bridge in this PR. DataFusion's regexp_replace
    natively accepts 4-arg `(str, pat, repl, flags)` per its substrait UDF binding.

  * `rex field=f mode=sed "y/from/to/"` (transliteration) → SqlLibraryOperators.TRANSLATE3.
    New bridge in this PR. Resolves to DataFusion's `translate` UDF
    (datafusion-functions/src/unicode/translate.rs).

## Why an adapter extension is necessary

The 4-arg `REGEXP_REPLACE_PG_4` carries the same Java-regex syntax baggage as the
3-arg form: `\Q…\E` quoted-literal blocks (Rust regex rejects them) and bare `$N`
backreferences in the replacement (Rust's identifier-greedy parser
mis-resolves them). RegexpReplaceAdapter, introduced for the 3-arg form in
opensearch-project#21527, is extended here to recognize 3 OR 4 operands. Pattern is at position 1
and replacement at position 2 in both signatures — the rewrite logic doesn't
change. Operands beyond position 2 (the flags string in the 4-arg form) pass
through verbatim. Two new RegexpReplaceAdapterTests cover the 4-arg path.

`TRANSLATE3` doesn't need an adapter — its arguments are character classes, not
regex syntax.

## Out of scope (deferred to Part 2)

  * Rex extract mode (`rex field=f "(?<g>...)"`) — uses the SQL plugin's custom
    Java UDFs `REX_EXTRACT`, `REX_EXTRACT_MULTI`, `REX_OFFSET`, which have no
    native DataFusion equivalent. Slated for a follow-up PR that adds Rust-side
    UDF implementations, similar to the convert_tz precedent (opensearch-project#21476).

  * Sed with occurrence flag (`s/.../.../<N>`) — emits 5-arg
    `REGEXP_REPLACE_5`, which DataFusion's native `regexp_replace` does not
    support (max 4 args). Also Part 2.

## Test results

  * `RegexpReplaceAdapterTests` — 21/21 (19 from opensearch-project#21527 + 2 new for the 4-arg path).
  * `RexCommandIT` (new self-contained QA IT, calcs dataset) — 9/9. Covers all sed
    sub-variants: literal (no flags), `/g` global, `/i` case-insensitive, `/gi`
    combined, backreferences via `$N`, transliteration `y/from/to/` and
    no-match passthrough.
  * `./gradlew check -p sandbox -Dsandbox.enabled=true` — green.

## Companion PR

The unified-path NPE caused by a missing PPL_REX_MAX_MATCH_LIMIT default is fixed
in opensearch-project/sql#5418 — required for any rex query (sed or extract) to
reach the planner via /_analytics/ppl. This PR's Test results assume opensearch-project#5418 is
applied. Pre-fix: every query NPEs in `AstBuilder.visitRexCommand`. Post-fix:
9/9 RexCommandIT pass.

Signed-off-by: Jialiang Liang <jiallian@amazon.com>
RyanL1997 added a commit to RyanL1997/OpenSearch that referenced this pull request May 8, 2026
…dge-only

Onboards the PPL `rex` command's `mode=sed` surface — the part that lowers to
standard Calcite library operators and bridges through Substrait to DataFusion's
native UDFs. Three sed sub-variants covered:

  * `rex field=f mode=sed "s/old/new/"` (no flags) → SqlLibraryOperators.REGEXP_REPLACE_3
    (already mapped via the PPL `replace` onboarding from opensearch-project#21527 — no-op here).

  * `rex field=f mode=sed "s/old/new/g"` / `/i` / `/gi` → SqlLibraryOperators.REGEXP_REPLACE_PG_4
    (4-arg with flags string). New bridge in this PR. DataFusion's regexp_replace
    natively accepts 4-arg `(str, pat, repl, flags)` per its substrait UDF binding.

  * `rex field=f mode=sed "y/from/to/"` (transliteration) → SqlLibraryOperators.TRANSLATE3.
    New bridge in this PR. Resolves to DataFusion's `translate` UDF
    (datafusion-functions/src/unicode/translate.rs).

## Why an adapter extension is necessary

The 4-arg `REGEXP_REPLACE_PG_4` carries the same Java-regex syntax baggage as the
3-arg form: `\Q…\E` quoted-literal blocks (Rust regex rejects them) and bare `$N`
backreferences in the replacement (Rust's identifier-greedy parser
mis-resolves them). RegexpReplaceAdapter, introduced for the 3-arg form in
opensearch-project#21527, is extended here to recognize 3 OR 4 operands. Pattern is at position 1
and replacement at position 2 in both signatures — the rewrite logic doesn't
change. Operands beyond position 2 (the flags string in the 4-arg form) pass
through verbatim. Two new RegexpReplaceAdapterTests cover the 4-arg path.

`TRANSLATE3` doesn't need an adapter — its arguments are character classes, not
regex syntax.

## Out of scope (deferred to Part 2)

  * Rex extract mode (`rex field=f "(?<g>...)"`) — uses the SQL plugin's custom
    Java UDFs `REX_EXTRACT`, `REX_EXTRACT_MULTI`, `REX_OFFSET`, which have no
    native DataFusion equivalent. Slated for a follow-up PR that adds Rust-side
    UDF implementations, similar to the convert_tz precedent (opensearch-project#21476).

  * Sed with occurrence flag (`s/.../.../<N>`) — emits 5-arg
    `REGEXP_REPLACE_5`, which DataFusion's native `regexp_replace` does not
    support (max 4 args). Also Part 2.

## Test results

  * `RegexpReplaceAdapterTests` — 21/21 (19 from opensearch-project#21527 + 2 new for the 4-arg path).
  * `RexCommandIT` (new self-contained QA IT, calcs dataset) — 9/9. Covers all sed
    sub-variants: literal (no flags), `/g` global, `/i` case-insensitive, `/gi`
    combined, backreferences via `$N`, transliteration `y/from/to/` and
    no-match passthrough.
  * `./gradlew check -p sandbox -Dsandbox.enabled=true` — green.

## Companion PR

The unified-path NPE caused by a missing PPL_REX_MAX_MATCH_LIMIT default is fixed
in opensearch-project/sql#5418 — required for any rex query (sed or extract) to
reach the planner via /_analytics/ppl. This PR's Test results assume opensearch-project#5418 is
applied. Pre-fix: every query NPEs in `AstBuilder.visitRexCommand`. Post-fix:
9/9 RexCommandIT pass.

Signed-off-by: Jialiang Liang <jiallian@amazon.com>
ahkcs added a commit to ahkcs/OpenSearch that referenced this pull request May 8, 2026
…ength / array_slice / array_distinct / mvjoin

Onboards the PPL `array(a, b, …)` constructor and four array-consuming
functions to the analytics-engine route by mapping their Calcite lowering
targets through Substrait to DataFusion's native make_array / array_length /
array_slice / array_distinct / array_to_string.

Same templated shape as the `replace` PR (opensearch-project#21527), with two extensions:

  ScalarFunction enum constants (5)
    + STANDARD_PROJECT_OPS / ARRAY_RETURNING_PROJECT_OPS membership
    + opensearch_array_functions.yaml extension entries
    + ADDITIONAL_SCALAR_SIGS Calcite-op→Substrait-name bridges
    + scalarFunctionAdapters() entries for the 3 functions that need
      operand normalization
    = onboarded to the analytics route.

Capability lookup at OpenSearchProjectRule keys on the call's return type;
for array-returning functions (`array(...)`, `array_slice`, `array_distinct`)
the return type resolves to `SqlTypeName.ARRAY`, which previously hit
`default → null` in `FieldType.fromSqlTypeName` and emptied the viable-backend
list before the registration could match.

  * `FieldType.ARRAY` added to the analytics SPI enum.
  * `SqlTypeName.ARRAY → FieldType.ARRAY` mapping in `fromSqlTypeName`.
  * `ARRAY_RETURNING_PROJECT_OPS` registered against `Set.of(FieldType.ARRAY)`
    only — separate from `STANDARD_PROJECT_OPS` so `FieldType.ARRAY` doesn't
    pollute filter / aggregate capabilities (no meaningful semantics over
    array-typed values there).
  * `ArrowSchemaFromCalcite.toArrowField` recurses into the component type
    to build the matching Arrow `List<inner>` field — without this the result
    schema would have a bare `List` with no element field and the backend's
    Arrow IPC reader would fail to bind result columns.

Substrait's standard catalog has no array_* entries, so isthmus'
`RexExpressionConverter` would fail with "Unable to convert call …" on every
array call. New `opensearch_array_functions.yaml` declares:

  * `make_array(any1, …)` → `list<any1>` (variadic, min: 0).
  * `array_length(list<any1>)` → `i64?`.
  * `array_slice(list<any1>, i64, i64)` → `list<any1>` (with i32 fallback).
  * `array_distinct(list<any1>)` → `list<any1>`.
  * `array_to_string(list<any1>, string)` → `string?` (with varchar fallback).

Loaded via `SimpleExtension.load("/opensearch_array_functions.yaml")` and
merged into the plugin's extension collection in
`DataFusionPlugin.loadSubstraitExtensions()`.

Substrait's call-conversion path (and DataFusion's signature matcher) is
strict about operand types in ways Calcite's PPL lowering doesn't naturally
satisfy. Three adapters bridge the gap:

  * `MakeArrayAdapter` — implements `ScalarFunctionAdapter` directly
    (not `AbstractNameMappingAdapter`). PPL's `ArrayFunctionImpl` infers
    `ARRAY<commonElementType>` for the call's return type but does NOT
    widen the individual operand types. So `array(1, 1.5)` produces a
    RexCall whose operands are `(INTEGER, DECIMAL(2,1))` but whose return
    type is `ARRAY<DOUBLE>`. Substrait's variadic-`any1` consistency
    validator throws an `AssertionError` in that case (not a recoverable
    exception — it fatally exits the search-thread JVM). The adapter
    extracts the call's component type and CASTs each non-matching
    operand to it before emission.
  * `ArrayToStringAdapter` — declares a local `array_to_string` op and
    name-maps `SqlLibraryOperators.ARRAY_JOIN` → it.
  * `ArraySliceAdapter` — passes the `ARRAY_SLICE` call through unchanged
    but coerces the index operands (positions 1, 2, optional 3) to
    `BIGINT`. PPL's parser types positive integer literals as
    `DECIMAL(20,0)`; DataFusion's `array_slice` signature accepts only
    integer indexes and refuses to coerce decimal arguments.

Two third-party dependencies that surfaced as fatal `NoClassDefFoundError`
during execution of array-returning calls:

  * `commons-text` to analytics-engine — Calcite's `SqlFunctions` class
    statically references `org.apache.commons.text.similarity.LevenshteinDistance`.
    Without it, any Calcite RelNode walk that touches `SqlFunctions.<clinit>`
    poisons the search-thread JVM.
  * `jackson-datatype-jsr310` to **arrow-flight-rpc** (the parent plugin
    that bundles `arrow-vector`). `arrow-vector`'s `JsonStringArrayList`
    eagerly registers `JavaTimeModule` on its ObjectMapper in `<clinit>`,
    so any reader of an Arrow `ListVector` (i.e. every array-returning
    DataFusion call flowing through analytics-engine) hits a fatal
    NoClassDefFoundError. The dep belongs on arrow-flight-rpc's classpath
    because that plugin defines arrow-vector's classloader; bundling it
    in analytics-backend-datafusion (the child plugin) is invisible to
    arrow-vector. Marked `compileOnly` here to avoid jar-hell with
    arrow-flight-rpc's `api` dependency.

  * Before: 1/60 (testArrayWithMix only — exercises an error path that
    fails before the ARRAY capability lookup).
  * After:  9/60.
    Newly passing: testArray, testArrayWithString, testArrayLength,
    testMvjoinWithStringArray, testMvjoinWithStringifiedNumbers,
    testMvjoinWithMixedStringValues, testMvjoinWithStringBooleans,
    testMvjoinWithSpecialDelimiters, testMvjoinWithArrayFromRealFields,
    testMvjoinWithMultipleRealFields.

The remaining 51 failures fall into three buckets:

  * 50 — out-of-scope S1+ functions (`mvfind`, `mvzip`, `reduce`, `transform`,
    `forall`, `filter`, `exists`, `ITEM`). These are PPL UDFs without direct
    DataFusion equivalents and need either lambda-substrait wiring or
    custom UDF registration on the Rust side.
  * 5  — `testMvindexRange*` family. PPL's `mvindex(arr, from, to)` lowers
    to `ARRAY_SLICE(arr, from+1, to+1)` (1-based shift) but the lowering
    is missing the +1, so DataFusion's 1-based array_slice returns a
    window shifted by one. Fix belongs in the SQL plugin's PPL→Calcite
    lowering layer.
  * 1  — `testMvindexRangeMixed` JSON formatting mismatch (test code
    expects bare `[a,b,c]` but the response is `\"[\\\"a\\\",\\\"b\\\",\\\"c\\\"]\"`).

Signed-off-by: Kai Huang <ahkcs@amazon.com>
RyanL1997 added a commit to RyanL1997/OpenSearch that referenced this pull request May 8, 2026
…dge-only

Onboards the PPL `rex` command's `mode=sed` surface — the part that lowers to
standard Calcite library operators and bridges through Substrait to DataFusion's
native UDFs. Three sed sub-variants covered:

  * `rex field=f mode=sed "s/old/new/"` (no flags) → SqlLibraryOperators.REGEXP_REPLACE_3
    (already mapped via the PPL `replace` onboarding from opensearch-project#21527 — no-op here).

  * `rex field=f mode=sed "s/old/new/g"` / `/i` / `/gi` → SqlLibraryOperators.REGEXP_REPLACE_PG_4
    (4-arg with flags string). New bridge in this PR. DataFusion's regexp_replace
    natively accepts 4-arg `(str, pat, repl, flags)` per its substrait UDF binding.

  * `rex field=f mode=sed "y/from/to/"` (transliteration) → SqlLibraryOperators.TRANSLATE3.
    New bridge in this PR. Resolves to DataFusion's `translate` UDF
    (datafusion-functions/src/unicode/translate.rs).

The 4-arg `REGEXP_REPLACE_PG_4` carries the same Java-regex syntax baggage as the
3-arg form: `\Q…\E` quoted-literal blocks (Rust regex rejects them) and bare `$N`
backreferences in the replacement (Rust's identifier-greedy parser
mis-resolves them). RegexpReplaceAdapter, introduced for the 3-arg form in
and replacement at position 2 in both signatures — the rewrite logic doesn't
change. Operands beyond position 2 (the flags string in the 4-arg form) pass
through verbatim. Two new RegexpReplaceAdapterTests cover the 4-arg path.

`TRANSLATE3` doesn't need an adapter — its arguments are character classes, not
regex syntax.

  * Rex extract mode (`rex field=f "(?<g>...)"`) — uses the SQL plugin's custom
    Java UDFs `REX_EXTRACT`, `REX_EXTRACT_MULTI`, `REX_OFFSET`, which have no
    native DataFusion equivalent. Slated for a follow-up PR that adds Rust-side
    UDF implementations, similar to the convert_tz precedent (opensearch-project#21476).

  * Sed with occurrence flag (`s/.../.../<N>`) — emits 5-arg
    `REGEXP_REPLACE_5`, which DataFusion's native `regexp_replace` does not
    support (max 4 args). Also Part 2.

  * `RegexpReplaceAdapterTests` — 21/21 (19 from opensearch-project#21527 + 2 new for the 4-arg path).
  * `RexCommandIT` (new self-contained QA IT, calcs dataset) — 9/9. Covers all sed
    sub-variants: literal (no flags), `/g` global, `/i` case-insensitive, `/gi`
    combined, backreferences via `$N`, transliteration `y/from/to/` and
    no-match passthrough.
  * `./gradlew check -p sandbox -Dsandbox.enabled=true` — green.

The unified-path NPE caused by a missing PPL_REX_MAX_MATCH_LIMIT default is fixed
in opensearch-project/sql#5418 — required for any rex query (sed or extract) to
reach the planner via /_analytics/ppl. This PR's Test results assume opensearch-project#5418 is
applied. Pre-fix: every query NPEs in `AstBuilder.visitRexCommand`. Post-fix:
9/9 RexCommandIT pass.

Signed-off-by: Jialiang Liang <jiallian@amazon.com>
imRishN pushed a commit to imRishN/OpenSearch that referenced this pull request May 8, 2026
…/regexp_replace() functions (opensearch-project#21527)

Onboards the PPL `replace` command and the `replace()` / `regexp_replace()`
eval functions to the analytics-engine route by mapping their two Calcite
lowering targets — `SqlStdOperatorTable.REPLACE` and
`SqlLibraryOperators.REGEXP_REPLACE_3` — through Substrait to DataFusion's
native `replace` and `regexp_replace` UDFs.

Same templated shape as the `fillnull` POC (opensearch-project#21472):

  ScalarFunction enum constant
    + STANDARD_PROJECT_OPS membership
    + opensearch_scalar_functions.yaml extension entry
    + ADDITIONAL_SCALAR_SIGS Calcite-op→Substrait-name bridge
    = onboarded to the analytics route.

Two scalar functions added: REPLACE (literal substring replace) and
REGEXP_REPLACE (regex replace). Both project-side only; the comparison
result of a replaced field is filtered via the existing EQUALS capability,
so no STANDARD_FILTER_OPS additions are needed.

PPL's wildcard `replace` form lowers via `WildcardUtils.convertWildcardPatternToRegex()`
to a Java-`Pattern`-compatible regex. Two flavors of Java syntax need
translation before substrait serialization, because DataFusion uses Rust's
`regex` crate which has different parsing rules:

  * `\Q…\E` quoted-literal blocks — Rust rejects `\Q` as an unrecognized
    escape sequence. The adapter expands each block to per-character
    escaped literals (semantics-preserving).
  * `$N` numeric backreferences in the replacement — Rust's replacement
    parser is identifier-greedy, so `$1_$2` is parsed as a reference to
    group named `1_` followed by `$2` (Java parses it as group 1 + literal
    underscore + group 2). The adapter wraps every numeric backreference
    in braces (`${N}`) for unambiguous Rust parsing.

Both transforms are in `RegexpReplaceAdapter` and registered against
`ScalarFunction.REGEXP_REPLACE` in `scalarFunctionAdapters()`. Calls
without `\Q` in the pattern AND without bare `$N` in the replacement pass
through unchanged.

  * `RegexpReplaceAdapterTests` — 19/19 (unquote: 9, brace: 7, dual-rewrite
    integration: 3).
  * `ReplaceCommandIT` (new self-contained QA IT, calcs dataset) — 10/10.
    Covers literal command (single + multi-pair = nested REPLACE), wildcard
    command (prefix + suffix), `replace()` and `regexp_replace()` in eval,
    full-row content checks, no-match passthrough, multi-field IN clause.
  * SQL plugin's `CalciteReplaceCommandIT` force-routed through the
    analytics-engine route via `-Dtests.analytics.{force_routing,parquet_indices}=true`
    — 21/21 in both the direct suite and the `CalciteNoPushdownIT` re-run.
    (Companion SQL plugin PR opensearch-project#5415 makes 4 column-order assertions and 1
    error-message assertion order-agnostic, mirroring the rename precedent
    from opensearch-project#5413.)

Unlike `fillnull`/`regex` where the bridge was a single one-line capability
addition, `replace`'s wildcard form exposes Java↔Rust regex syntax
divergence. The adapter is reusable for any future Calcite operator whose
PPL lowering goes through `WildcardUtils` (e.g. potential future patterns
in `like`-with-escape, custom regex lowerings).

Signed-off-by: Jialiang Liang <jiallian@amazon.com>
RyanL1997 added a commit to RyanL1997/OpenSearch that referenced this pull request May 8, 2026
…dge-only

Onboards the PPL `rex` command's `mode=sed` surface — the part that lowers to
standard Calcite library operators and bridges through Substrait to DataFusion's
native UDFs. Three sed sub-variants covered:

  * `rex field=f mode=sed "s/old/new/"` (no flags) → SqlLibraryOperators.REGEXP_REPLACE_3
    (already mapped via the PPL `replace` onboarding from opensearch-project#21527 — no-op here).

  * `rex field=f mode=sed "s/old/new/g"` / `/i` / `/gi` → SqlLibraryOperators.REGEXP_REPLACE_PG_4
    (4-arg with flags string). New bridge in this PR. DataFusion's regexp_replace
    natively accepts 4-arg `(str, pat, repl, flags)` per its substrait UDF binding.

  * `rex field=f mode=sed "y/from/to/"` (transliteration) → SqlLibraryOperators.TRANSLATE3.
    New bridge in this PR. Resolves to DataFusion's `translate` UDF
    (datafusion-functions/src/unicode/translate.rs).

The 4-arg `REGEXP_REPLACE_PG_4` carries the same Java-regex syntax baggage as the
3-arg form: `\Q…\E` quoted-literal blocks (Rust regex rejects them) and bare `$N`
backreferences in the replacement (Rust's identifier-greedy parser
mis-resolves them). RegexpReplaceAdapter, introduced for the 3-arg form in
and replacement at position 2 in both signatures — the rewrite logic doesn't
change. Operands beyond position 2 (the flags string in the 4-arg form) pass
through verbatim. Two new RegexpReplaceAdapterTests cover the 4-arg path.

`TRANSLATE3` doesn't need an adapter — its arguments are character classes, not
regex syntax.

  * Rex extract mode (`rex field=f "(?<g>...)"`) — uses the SQL plugin's custom
    Java UDFs `REX_EXTRACT`, `REX_EXTRACT_MULTI`, `REX_OFFSET`, which have no
    native DataFusion equivalent. Slated for a follow-up PR that adds Rust-side
    UDF implementations, similar to the convert_tz precedent (opensearch-project#21476).

  * Sed with occurrence flag (`s/.../.../<N>`) — emits 5-arg
    `REGEXP_REPLACE_5`, which DataFusion's native `regexp_replace` does not
    support (max 4 args). Also Part 2.

  * `RegexpReplaceAdapterTests` — 21/21 (19 from opensearch-project#21527 + 2 new for the 4-arg path).
  * `RexCommandIT` (new self-contained QA IT, calcs dataset) — 9/9. Covers all sed
    sub-variants: literal (no flags), `/g` global, `/i` case-insensitive, `/gi`
    combined, backreferences via `$N`, transliteration `y/from/to/` and
    no-match passthrough.
  * `./gradlew check -p sandbox -Dsandbox.enabled=true` — green.

The unified-path NPE caused by a missing PPL_REX_MAX_MATCH_LIMIT default is fixed
in opensearch-project/sql#5418 — required for any rex query (sed or extract) to
reach the planner via /_analytics/ppl. This PR's Test results assume opensearch-project#5418 is
applied. Pre-fix: every query NPEs in `AstBuilder.visitRexCommand`. Post-fix:
9/9 RexCommandIT pass.

Signed-off-by: Jialiang Liang <jiallian@amazon.com>
RyanL1997 added a commit to RyanL1997/OpenSearch that referenced this pull request May 8, 2026
…dge-only

Onboards the PPL `rex` command's `mode=sed` surface — the part that lowers to
standard Calcite library operators and bridges through Substrait to DataFusion's
native UDFs. Three sed sub-variants covered:

  * `rex field=f mode=sed "s/old/new/"` (no flags) → SqlLibraryOperators.REGEXP_REPLACE_3
    (already mapped via the PPL `replace` onboarding from opensearch-project#21527 — no-op here).

  * `rex field=f mode=sed "s/old/new/g"` / `/i` / `/gi` → SqlLibraryOperators.REGEXP_REPLACE_PG_4
    (4-arg with flags string). New bridge in this PR. DataFusion's regexp_replace
    natively accepts 4-arg `(str, pat, repl, flags)` per its substrait UDF binding.

  * `rex field=f mode=sed "y/from/to/"` (transliteration) → SqlLibraryOperators.TRANSLATE3.
    New bridge in this PR. Resolves to DataFusion's `translate` UDF
    (datafusion-functions/src/unicode/translate.rs).

The 4-arg `REGEXP_REPLACE_PG_4` carries the same Java-regex syntax baggage as the
3-arg form: `\Q…\E` quoted-literal blocks (Rust regex rejects them) and bare `$N`
backreferences in the replacement (Rust's identifier-greedy parser
mis-resolves them). RegexpReplaceAdapter, introduced for the 3-arg form in
and replacement at position 2 in both signatures — the rewrite logic doesn't
change. Operands beyond position 2 (the flags string in the 4-arg form) pass
through verbatim. Two new RegexpReplaceAdapterTests cover the 4-arg path.

`TRANSLATE3` doesn't need an adapter — its arguments are character classes, not
regex syntax.

  * Rex extract mode (`rex field=f "(?<g>...)"`) — uses the SQL plugin's custom
    Java UDFs `REX_EXTRACT`, `REX_EXTRACT_MULTI`, `REX_OFFSET`, which have no
    native DataFusion equivalent. Slated for a follow-up PR that adds Rust-side
    UDF implementations, similar to the convert_tz precedent (opensearch-project#21476).

  * Sed with occurrence flag (`s/.../.../<N>`) — emits 5-arg
    `REGEXP_REPLACE_5`, which DataFusion's native `regexp_replace` does not
    support (max 4 args). Also Part 2.

  * `RegexpReplaceAdapterTests` — 21/21 (19 from opensearch-project#21527 + 2 new for the 4-arg path).
  * `RexCommandIT` (new self-contained QA IT, calcs dataset) — 9/9. Covers all sed
    sub-variants: literal (no flags), `/g` global, `/i` case-insensitive, `/gi`
    combined, backreferences via `$N`, transliteration `y/from/to/` and
    no-match passthrough.
  * `./gradlew check -p sandbox -Dsandbox.enabled=true` — green.

The unified-path NPE caused by a missing PPL_REX_MAX_MATCH_LIMIT default is fixed
in opensearch-project/sql#5418 — required for any rex query (sed or extract) to
reach the planner via /_analytics/ppl. This PR's Test results assume opensearch-project#5418 is
applied. Pre-fix: every query NPEs in `AstBuilder.visitRexCommand`. Post-fix:
9/9 RexCommandIT pass.

Signed-off-by: Jialiang Liang <jiallian@amazon.com>
ahkcs added a commit to ahkcs/OpenSearch that referenced this pull request May 8, 2026
…ength / array_slice / array_distinct / mvjoin

Onboards the PPL `array(a, b, …)` constructor and four array-consuming
functions to the analytics-engine route by mapping their Calcite lowering
targets through Substrait to DataFusion's native make_array / array_length /
array_slice / array_distinct / array_to_string.

Same templated shape as the `replace` PR (opensearch-project#21527), with two extensions:

  ScalarFunction enum constants (5)
    + STANDARD_PROJECT_OPS / ARRAY_RETURNING_PROJECT_OPS membership
    + opensearch_array_functions.yaml extension entries
    + ADDITIONAL_SCALAR_SIGS Calcite-op→Substrait-name bridges
    + scalarFunctionAdapters() entries for the 3 functions that need
      operand normalization
    = onboarded to the analytics route.

Capability lookup at OpenSearchProjectRule keys on the call's return type;
for array-returning functions (`array(...)`, `array_slice`, `array_distinct`)
the return type resolves to `SqlTypeName.ARRAY`, which previously hit
`default → null` in `FieldType.fromSqlTypeName` and emptied the viable-backend
list before the registration could match.

  * `FieldType.ARRAY` added to the analytics SPI enum.
  * `SqlTypeName.ARRAY → FieldType.ARRAY` mapping in `fromSqlTypeName`.
  * `ARRAY_RETURNING_PROJECT_OPS` registered against `Set.of(FieldType.ARRAY)`
    only — separate from `STANDARD_PROJECT_OPS` so `FieldType.ARRAY` doesn't
    pollute filter / aggregate capabilities (no meaningful semantics over
    array-typed values there).
  * `ArrowSchemaFromCalcite.toArrowField` recurses into the component type
    to build the matching Arrow `List<inner>` field — without this the result
    schema would have a bare `List` with no element field and the backend's
    Arrow IPC reader would fail to bind result columns.

Substrait's standard catalog has no array_* entries, so isthmus'
`RexExpressionConverter` would fail with "Unable to convert call …" on every
array call. New `opensearch_array_functions.yaml` declares:

  * `make_array(any1, …)` → `list<any1>` (variadic, min: 0).
  * `array_length(list<any1>)` → `i64?`.
  * `array_slice(list<any1>, i64, i64)` → `list<any1>` (with i32 fallback).
  * `array_distinct(list<any1>)` → `list<any1>`.
  * `array_to_string(list<any1>, string)` → `string?` (with varchar fallback).

Loaded via `SimpleExtension.load("/opensearch_array_functions.yaml")` and
merged into the plugin's extension collection in
`DataFusionPlugin.loadSubstraitExtensions()`.

Substrait's call-conversion path (and DataFusion's signature matcher) is
strict about operand types in ways Calcite's PPL lowering doesn't naturally
satisfy. Three adapters bridge the gap:

  * `MakeArrayAdapter` — implements `ScalarFunctionAdapter` directly
    (not `AbstractNameMappingAdapter`). PPL's `ArrayFunctionImpl` infers
    `ARRAY<commonElementType>` for the call's return type but does NOT
    widen the individual operand types. So `array(1, 1.5)` produces a
    RexCall whose operands are `(INTEGER, DECIMAL(2,1))` but whose return
    type is `ARRAY<DOUBLE>`. Substrait's variadic-`any1` consistency
    validator throws an `AssertionError` in that case (not a recoverable
    exception — it fatally exits the search-thread JVM). The adapter
    extracts the call's component type and CASTs each non-matching
    operand to it before emission.
  * `ArrayToStringAdapter` — declares a local `array_to_string` op and
    name-maps `SqlLibraryOperators.ARRAY_JOIN` → it.
  * `ArraySliceAdapter` — passes the `ARRAY_SLICE` call through unchanged
    but coerces the index operands (positions 1, 2, optional 3) to
    `BIGINT`. PPL's parser types positive integer literals as
    `DECIMAL(20,0)`; DataFusion's `array_slice` signature accepts only
    integer indexes and refuses to coerce decimal arguments.

Two third-party dependencies that surfaced as fatal `NoClassDefFoundError`
during execution of array-returning calls:

  * `commons-text` to analytics-engine — Calcite's `SqlFunctions` class
    statically references `org.apache.commons.text.similarity.LevenshteinDistance`.
    Without it, any Calcite RelNode walk that touches `SqlFunctions.<clinit>`
    poisons the search-thread JVM.
  * `jackson-datatype-jsr310` to **arrow-flight-rpc** (the parent plugin
    that bundles `arrow-vector`). `arrow-vector`'s `JsonStringArrayList`
    eagerly registers `JavaTimeModule` on its ObjectMapper in `<clinit>`,
    so any reader of an Arrow `ListVector` (i.e. every array-returning
    DataFusion call flowing through analytics-engine) hits a fatal
    NoClassDefFoundError. The dep belongs on arrow-flight-rpc's classpath
    because that plugin defines arrow-vector's classloader; bundling it
    in analytics-backend-datafusion (the child plugin) is invisible to
    arrow-vector. Marked `compileOnly` here to avoid jar-hell with
    arrow-flight-rpc's `api` dependency.

  * Before: 1/60 (testArrayWithMix only — exercises an error path that
    fails before the ARRAY capability lookup).
  * After:  9/60.
    Newly passing: testArray, testArrayWithString, testArrayLength,
    testMvjoinWithStringArray, testMvjoinWithStringifiedNumbers,
    testMvjoinWithMixedStringValues, testMvjoinWithStringBooleans,
    testMvjoinWithSpecialDelimiters, testMvjoinWithArrayFromRealFields,
    testMvjoinWithMultipleRealFields.

The remaining 51 failures fall into three buckets:

  * 50 — out-of-scope S1+ functions (`mvfind`, `mvzip`, `reduce`, `transform`,
    `forall`, `filter`, `exists`, `ITEM`). These are PPL UDFs without direct
    DataFusion equivalents and need either lambda-substrait wiring or
    custom UDF registration on the Rust side.
  * 5  — `testMvindexRange*` family. PPL's `mvindex(arr, from, to)` lowers
    to `ARRAY_SLICE(arr, from+1, to+1)` (1-based shift) but the lowering
    is missing the +1, so DataFusion's 1-based array_slice returns a
    window shifted by one. Fix belongs in the SQL plugin's PPL→Calcite
    lowering layer.
  * 1  — `testMvindexRangeMixed` JSON formatting mismatch (test code
    expects bare `[a,b,c]` but the response is `\"[\\\"a\\\",\\\"b\\\",\\\"c\\\"]\"`).

Signed-off-by: Kai Huang <ahkcs@amazon.com>
ahkcs added a commit to ahkcs/OpenSearch that referenced this pull request May 8, 2026
…ength / array_slice / array_distinct / mvjoin

Onboards the PPL `array(a, b, …)` constructor and four array-consuming
functions to the analytics-engine route by mapping their Calcite lowering
targets through Substrait to DataFusion's native make_array / array_length /
array_slice / array_distinct / array_to_string.

Same templated shape as the `replace` PR (opensearch-project#21527), with two extensions:

  ScalarFunction enum constants (5)
    + STANDARD_PROJECT_OPS / ARRAY_RETURNING_PROJECT_OPS membership
    + opensearch_array_functions.yaml extension entries
    + ADDITIONAL_SCALAR_SIGS Calcite-op→Substrait-name bridges
    + scalarFunctionAdapters() entries for the 3 functions that need
      operand normalization
    = onboarded to the analytics route.

Capability lookup at OpenSearchProjectRule keys on the call's return type;
for array-returning functions (`array(...)`, `array_slice`, `array_distinct`)
the return type resolves to `SqlTypeName.ARRAY`, which previously hit
`default → null` in `FieldType.fromSqlTypeName` and emptied the viable-backend
list before the registration could match.

  * `FieldType.ARRAY` added to the analytics SPI enum.
  * `SqlTypeName.ARRAY → FieldType.ARRAY` mapping in `fromSqlTypeName`.
  * `ARRAY_RETURNING_PROJECT_OPS` registered against `Set.of(FieldType.ARRAY)`
    only — separate from `STANDARD_PROJECT_OPS` so `FieldType.ARRAY` doesn't
    pollute filter / aggregate capabilities (no meaningful semantics over
    array-typed values there).
  * `ArrowSchemaFromCalcite.toArrowField` recurses into the component type
    to build the matching Arrow `List<inner>` field — without this the result
    schema would have a bare `List` with no element field and the backend's
    Arrow IPC reader would fail to bind result columns.

Substrait's standard catalog has no array_* entries, so isthmus'
`RexExpressionConverter` would fail with "Unable to convert call …" on every
array call. New `opensearch_array_functions.yaml` declares:

  * `make_array(any1, …)` → `list<any1>` (variadic, min: 0).
  * `array_length(list<any1>)` → `i64?`.
  * `array_slice(list<any1>, i64, i64)` → `list<any1>` (with i32 fallback).
  * `array_distinct(list<any1>)` → `list<any1>`.
  * `array_to_string(list<any1>, string)` → `string?` (with varchar fallback).

Loaded via `SimpleExtension.load("/opensearch_array_functions.yaml")` and
merged into the plugin's extension collection in
`DataFusionPlugin.loadSubstraitExtensions()`.

Substrait's call-conversion path (and DataFusion's signature matcher) is
strict about operand types in ways Calcite's PPL lowering doesn't naturally
satisfy. Three adapters bridge the gap:

  * `MakeArrayAdapter` — implements `ScalarFunctionAdapter` directly
    (not `AbstractNameMappingAdapter`). PPL's `ArrayFunctionImpl` infers
    `ARRAY<commonElementType>` for the call's return type but does NOT
    widen the individual operand types. So `array(1, 1.5)` produces a
    RexCall whose operands are `(INTEGER, DECIMAL(2,1))` but whose return
    type is `ARRAY<DOUBLE>`. Substrait's variadic-`any1` consistency
    validator throws an `AssertionError` in that case (not a recoverable
    exception — it fatally exits the search-thread JVM). The adapter
    extracts the call's component type and CASTs each non-matching
    operand to it before emission.
  * `ArrayToStringAdapter` — declares a local `array_to_string` op and
    name-maps `SqlLibraryOperators.ARRAY_JOIN` → it.
  * `ArraySliceAdapter` — passes the `ARRAY_SLICE` call through unchanged
    but coerces the index operands (positions 1, 2, optional 3) to
    `BIGINT`. PPL's parser types positive integer literals as
    `DECIMAL(20,0)`; DataFusion's `array_slice` signature accepts only
    integer indexes and refuses to coerce decimal arguments.

Two third-party dependencies that surfaced as fatal `NoClassDefFoundError`
during execution of array-returning calls:

  * `commons-text` to analytics-engine — Calcite's `SqlFunctions` class
    statically references `org.apache.commons.text.similarity.LevenshteinDistance`.
    Without it, any Calcite RelNode walk that touches `SqlFunctions.<clinit>`
    poisons the search-thread JVM.
  * `jackson-datatype-jsr310` to **arrow-flight-rpc** (the parent plugin
    that bundles `arrow-vector`). `arrow-vector`'s `JsonStringArrayList`
    eagerly registers `JavaTimeModule` on its ObjectMapper in `<clinit>`,
    so any reader of an Arrow `ListVector` (i.e. every array-returning
    DataFusion call flowing through analytics-engine) hits a fatal
    NoClassDefFoundError. The dep belongs on arrow-flight-rpc's classpath
    because that plugin defines arrow-vector's classloader; bundling it
    in analytics-backend-datafusion (the child plugin) is invisible to
    arrow-vector. Marked `compileOnly` here to avoid jar-hell with
    arrow-flight-rpc's `api` dependency.

  * Before: 1/60 (testArrayWithMix only — exercises an error path that
    fails before the ARRAY capability lookup).
  * After:  9/60.
    Newly passing: testArray, testArrayWithString, testArrayLength,
    testMvjoinWithStringArray, testMvjoinWithStringifiedNumbers,
    testMvjoinWithMixedStringValues, testMvjoinWithStringBooleans,
    testMvjoinWithSpecialDelimiters, testMvjoinWithArrayFromRealFields,
    testMvjoinWithMultipleRealFields.

The remaining 51 failures fall into three buckets:

  * 50 — out-of-scope S1+ functions (`mvfind`, `mvzip`, `reduce`, `transform`,
    `forall`, `filter`, `exists`, `ITEM`). These are PPL UDFs without direct
    DataFusion equivalents and need either lambda-substrait wiring or
    custom UDF registration on the Rust side.
  * 5  — `testMvindexRange*` family. PPL's `mvindex(arr, from, to)` lowers
    to `ARRAY_SLICE(arr, from+1, to+1)` (1-based shift) but the lowering
    is missing the +1, so DataFusion's 1-based array_slice returns a
    window shifted by one. Fix belongs in the SQL plugin's PPL→Calcite
    lowering layer.
  * 1  — `testMvindexRangeMixed` JSON formatting mismatch (test code
    expects bare `[a,b,c]` but the response is `\"[\\\"a\\\",\\\"b\\\",\\\"c\\\"]\"`).

Signed-off-by: Kai Huang <ahkcs@amazon.com>
RyanL1997 added a commit to RyanL1997/OpenSearch that referenced this pull request May 8, 2026
…dge-only

Onboards the PPL `rex` command's `mode=sed` surface — the part that lowers to
standard Calcite library operators and bridges through Substrait to DataFusion's
native UDFs. Three sed sub-variants covered:

  * `rex field=f mode=sed "s/old/new/"` (no flags) → SqlLibraryOperators.REGEXP_REPLACE_3
    (already mapped via the PPL `replace` onboarding from opensearch-project#21527 — no-op here).

  * `rex field=f mode=sed "s/old/new/g"` / `/i` / `/gi` → SqlLibraryOperators.REGEXP_REPLACE_PG_4
    (4-arg with flags string). New bridge in this PR. DataFusion's regexp_replace
    natively accepts 4-arg `(str, pat, repl, flags)` per its substrait UDF binding.

  * `rex field=f mode=sed "y/from/to/"` (transliteration) → SqlLibraryOperators.TRANSLATE3.
    New bridge in this PR. Resolves to DataFusion's `translate` UDF
    (datafusion-functions/src/unicode/translate.rs).

The 4-arg `REGEXP_REPLACE_PG_4` carries the same Java-regex syntax baggage as the
3-arg form: `\Q…\E` quoted-literal blocks (Rust regex rejects them) and bare `$N`
backreferences in the replacement (Rust's identifier-greedy parser
mis-resolves them). RegexpReplaceAdapter, introduced for the 3-arg form in
and replacement at position 2 in both signatures — the rewrite logic doesn't
change. Operands beyond position 2 (the flags string in the 4-arg form) pass
through verbatim. Two new RegexpReplaceAdapterTests cover the 4-arg path.

`TRANSLATE3` doesn't need an adapter — its arguments are character classes, not
regex syntax.

  * Rex extract mode (`rex field=f "(?<g>...)"`) — uses the SQL plugin's custom
    Java UDFs `REX_EXTRACT`, `REX_EXTRACT_MULTI`, `REX_OFFSET`, which have no
    native DataFusion equivalent. Slated for a follow-up PR that adds Rust-side
    UDF implementations, similar to the convert_tz precedent (opensearch-project#21476).

  * Sed with occurrence flag (`s/.../.../<N>`) — emits 5-arg
    `REGEXP_REPLACE_5`, which DataFusion's native `regexp_replace` does not
    support (max 4 args). Also Part 2.

  * `RegexpReplaceAdapterTests` — 21/21 (19 from opensearch-project#21527 + 2 new for the 4-arg path).
  * `RexCommandIT` (new self-contained QA IT, calcs dataset) — 9/9. Covers all sed
    sub-variants: literal (no flags), `/g` global, `/i` case-insensitive, `/gi`
    combined, backreferences via `$N`, transliteration `y/from/to/` and
    no-match passthrough.
  * `./gradlew check -p sandbox -Dsandbox.enabled=true` — green.

The unified-path NPE caused by a missing PPL_REX_MAX_MATCH_LIMIT default is fixed
in opensearch-project/sql#5418 — required for any rex query (sed or extract) to
reach the planner via /_analytics/ppl. This PR's Test results assume opensearch-project#5418 is
applied. Pre-fix: every query NPEs in `AstBuilder.visitRexCommand`. Post-fix:
9/9 RexCommandIT pass.

Signed-off-by: Jialiang Liang <jiallian@amazon.com>
RyanL1997 added a commit to RyanL1997/OpenSearch that referenced this pull request May 9, 2026
…dge-only

Onboards the PPL `rex` command's `mode=sed` surface — the part that lowers to
standard Calcite library operators and bridges through Substrait to DataFusion's
native UDFs. Three sed sub-variants covered:

  * `rex field=f mode=sed "s/old/new/"` (no flags) → SqlLibraryOperators.REGEXP_REPLACE_3
    (already mapped via the PPL `replace` onboarding from opensearch-project#21527 — no-op here).

  * `rex field=f mode=sed "s/old/new/g"` / `/i` / `/gi` → SqlLibraryOperators.REGEXP_REPLACE_PG_4
    (4-arg with flags string). New bridge in this PR. DataFusion's regexp_replace
    natively accepts 4-arg `(str, pat, repl, flags)` per its substrait UDF binding.

  * `rex field=f mode=sed "y/from/to/"` (transliteration) → SqlLibraryOperators.TRANSLATE3.
    New bridge in this PR. Resolves to DataFusion's `translate` UDF
    (datafusion-functions/src/unicode/translate.rs).

The 4-arg `REGEXP_REPLACE_PG_4` carries the same Java-regex syntax baggage as the
3-arg form: `\Q…\E` quoted-literal blocks (Rust regex rejects them) and bare `$N`
backreferences in the replacement (Rust's identifier-greedy parser
mis-resolves them). RegexpReplaceAdapter, introduced for the 3-arg form in
and replacement at position 2 in both signatures — the rewrite logic doesn't
change. Operands beyond position 2 (the flags string in the 4-arg form) pass
through verbatim. Two new RegexpReplaceAdapterTests cover the 4-arg path.

`TRANSLATE3` doesn't need an adapter — its arguments are character classes, not
regex syntax.

  * Rex extract mode (`rex field=f "(?<g>...)"`) — uses the SQL plugin's custom
    Java UDFs `REX_EXTRACT`, `REX_EXTRACT_MULTI`, `REX_OFFSET`, which have no
    native DataFusion equivalent. Slated for a follow-up PR that adds Rust-side
    UDF implementations, similar to the convert_tz precedent (opensearch-project#21476).

  * Sed with occurrence flag (`s/.../.../<N>`) — emits 5-arg
    `REGEXP_REPLACE_5`, which DataFusion's native `regexp_replace` does not
    support (max 4 args). Also Part 2.

  * `RegexpReplaceAdapterTests` — 21/21 (19 from opensearch-project#21527 + 2 new for the 4-arg path).
  * `RexCommandIT` (new self-contained QA IT, calcs dataset) — 9/9. Covers all sed
    sub-variants: literal (no flags), `/g` global, `/i` case-insensitive, `/gi`
    combined, backreferences via `$N`, transliteration `y/from/to/` and
    no-match passthrough.
  * `./gradlew check -p sandbox -Dsandbox.enabled=true` — green.

The unified-path NPE caused by a missing PPL_REX_MAX_MATCH_LIMIT default is fixed
in opensearch-project/sql#5418 — required for any rex query (sed or extract) to
reach the planner via /_analytics/ppl. This PR's Test results assume opensearch-project#5418 is
applied. Pre-fix: every query NPEs in `AstBuilder.visitRexCommand`. Post-fix:
9/9 RexCommandIT pass.

Signed-off-by: Jialiang Liang <jiallian@amazon.com>
ahkcs added a commit to ahkcs/OpenSearch that referenced this pull request May 9, 2026
…ength / array_slice / array_distinct / mvjoin

Onboards the PPL `array(a, b, …)` constructor and four array-consuming
functions to the analytics-engine route by mapping their Calcite lowering
targets through Substrait to DataFusion's native make_array / array_length /
array_slice / array_distinct / array_to_string.

Same templated shape as the `replace` PR (opensearch-project#21527), with two extensions:

  ScalarFunction enum constants (5)
    + STANDARD_PROJECT_OPS / ARRAY_RETURNING_PROJECT_OPS membership
    + opensearch_array_functions.yaml extension entries
    + ADDITIONAL_SCALAR_SIGS Calcite-op→Substrait-name bridges
    + scalarFunctionAdapters() entries for the 3 functions that need
      operand normalization
    = onboarded to the analytics route.

Capability lookup at OpenSearchProjectRule keys on the call's return type;
for array-returning functions (`array(...)`, `array_slice`, `array_distinct`)
the return type resolves to `SqlTypeName.ARRAY`, which previously hit
`default → null` in `FieldType.fromSqlTypeName` and emptied the viable-backend
list before the registration could match.

  * `FieldType.ARRAY` added to the analytics SPI enum.
  * `SqlTypeName.ARRAY → FieldType.ARRAY` mapping in `fromSqlTypeName`.
  * `ARRAY_RETURNING_PROJECT_OPS` registered against `Set.of(FieldType.ARRAY)`
    only — separate from `STANDARD_PROJECT_OPS` so `FieldType.ARRAY` doesn't
    pollute filter / aggregate capabilities (no meaningful semantics over
    array-typed values there).
  * `ArrowSchemaFromCalcite.toArrowField` recurses into the component type
    to build the matching Arrow `List<inner>` field — without this the result
    schema would have a bare `List` with no element field and the backend's
    Arrow IPC reader would fail to bind result columns.

Substrait's standard catalog has no array_* entries, so isthmus'
`RexExpressionConverter` would fail with "Unable to convert call …" on every
array call. New `opensearch_array_functions.yaml` declares:

  * `make_array(any1, …)` → `list<any1>` (variadic, min: 0).
  * `array_length(list<any1>)` → `i64?`.
  * `array_slice(list<any1>, i64, i64)` → `list<any1>` (with i32 fallback).
  * `array_distinct(list<any1>)` → `list<any1>`.
  * `array_to_string(list<any1>, string)` → `string?` (with varchar fallback).

Loaded via `SimpleExtension.load("/opensearch_array_functions.yaml")` and
merged into the plugin's extension collection in
`DataFusionPlugin.loadSubstraitExtensions()`.

Substrait's call-conversion path (and DataFusion's signature matcher) is
strict about operand types in ways Calcite's PPL lowering doesn't naturally
satisfy. Three adapters bridge the gap:

  * `MakeArrayAdapter` — implements `ScalarFunctionAdapter` directly
    (not `AbstractNameMappingAdapter`). PPL's `ArrayFunctionImpl` infers
    `ARRAY<commonElementType>` for the call's return type but does NOT
    widen the individual operand types. So `array(1, 1.5)` produces a
    RexCall whose operands are `(INTEGER, DECIMAL(2,1))` but whose return
    type is `ARRAY<DOUBLE>`. Substrait's variadic-`any1` consistency
    validator throws an `AssertionError` in that case (not a recoverable
    exception — it fatally exits the search-thread JVM). The adapter
    extracts the call's component type and CASTs each non-matching
    operand to it before emission.
  * `ArrayToStringAdapter` — declares a local `array_to_string` op and
    name-maps `SqlLibraryOperators.ARRAY_JOIN` → it.
  * `ArraySliceAdapter` — passes the `ARRAY_SLICE` call through unchanged
    but coerces the index operands (positions 1, 2, optional 3) to
    `BIGINT`. PPL's parser types positive integer literals as
    `DECIMAL(20,0)`; DataFusion's `array_slice` signature accepts only
    integer indexes and refuses to coerce decimal arguments.

Two third-party dependencies that surfaced as fatal `NoClassDefFoundError`
during execution of array-returning calls:

  * `commons-text` to analytics-engine — Calcite's `SqlFunctions` class
    statically references `org.apache.commons.text.similarity.LevenshteinDistance`.
    Without it, any Calcite RelNode walk that touches `SqlFunctions.<clinit>`
    poisons the search-thread JVM.
  * `jackson-datatype-jsr310` to **arrow-flight-rpc** (the parent plugin
    that bundles `arrow-vector`). `arrow-vector`'s `JsonStringArrayList`
    eagerly registers `JavaTimeModule` on its ObjectMapper in `<clinit>`,
    so any reader of an Arrow `ListVector` (i.e. every array-returning
    DataFusion call flowing through analytics-engine) hits a fatal
    NoClassDefFoundError. The dep belongs on arrow-flight-rpc's classpath
    because that plugin defines arrow-vector's classloader; bundling it
    in analytics-backend-datafusion (the child plugin) is invisible to
    arrow-vector. Marked `compileOnly` here to avoid jar-hell with
    arrow-flight-rpc's `api` dependency.

  * Before: 1/60 (testArrayWithMix only — exercises an error path that
    fails before the ARRAY capability lookup).
  * After:  9/60.
    Newly passing: testArray, testArrayWithString, testArrayLength,
    testMvjoinWithStringArray, testMvjoinWithStringifiedNumbers,
    testMvjoinWithMixedStringValues, testMvjoinWithStringBooleans,
    testMvjoinWithSpecialDelimiters, testMvjoinWithArrayFromRealFields,
    testMvjoinWithMultipleRealFields.

The remaining 51 failures fall into three buckets:

  * 50 — out-of-scope S1+ functions (`mvfind`, `mvzip`, `reduce`, `transform`,
    `forall`, `filter`, `exists`, `ITEM`). These are PPL UDFs without direct
    DataFusion equivalents and need either lambda-substrait wiring or
    custom UDF registration on the Rust side.
  * 5  — `testMvindexRange*` family. PPL's `mvindex(arr, from, to)` lowers
    to `ARRAY_SLICE(arr, from+1, to+1)` (1-based shift) but the lowering
    is missing the +1, so DataFusion's 1-based array_slice returns a
    window shifted by one. Fix belongs in the SQL plugin's PPL→Calcite
    lowering layer.
  * 1  — `testMvindexRangeMixed` JSON formatting mismatch (test code
    expects bare `[a,b,c]` but the response is `\"[\\\"a\\\",\\\"b\\\",\\\"c\\\"]\"`).

Signed-off-by: Kai Huang <ahkcs@amazon.com>
mch2 pushed a commit that referenced this pull request May 9, 2026
…multivalue (mv) functions to analytics-engine route (#21554)

* [Analytics Backend / DataFusion] Wire PPL array constructor + array_length / array_slice / array_distinct / mvjoin

Onboards the PPL `array(a, b, …)` constructor and four array-consuming
functions to the analytics-engine route by mapping their Calcite lowering
targets through Substrait to DataFusion's native make_array / array_length /
array_slice / array_distinct / array_to_string.

Same templated shape as the `replace` PR (#21527), with two extensions:

  ScalarFunction enum constants (5)
    + STANDARD_PROJECT_OPS / ARRAY_RETURNING_PROJECT_OPS membership
    + opensearch_array_functions.yaml extension entries
    + ADDITIONAL_SCALAR_SIGS Calcite-op→Substrait-name bridges
    + scalarFunctionAdapters() entries for the 3 functions that need
      operand normalization
    = onboarded to the analytics route.

Capability lookup at OpenSearchProjectRule keys on the call's return type;
for array-returning functions (`array(...)`, `array_slice`, `array_distinct`)
the return type resolves to `SqlTypeName.ARRAY`, which previously hit
`default → null` in `FieldType.fromSqlTypeName` and emptied the viable-backend
list before the registration could match.

  * `FieldType.ARRAY` added to the analytics SPI enum.
  * `SqlTypeName.ARRAY → FieldType.ARRAY` mapping in `fromSqlTypeName`.
  * `ARRAY_RETURNING_PROJECT_OPS` registered against `Set.of(FieldType.ARRAY)`
    only — separate from `STANDARD_PROJECT_OPS` so `FieldType.ARRAY` doesn't
    pollute filter / aggregate capabilities (no meaningful semantics over
    array-typed values there).
  * `ArrowSchemaFromCalcite.toArrowField` recurses into the component type
    to build the matching Arrow `List<inner>` field — without this the result
    schema would have a bare `List` with no element field and the backend's
    Arrow IPC reader would fail to bind result columns.

Substrait's standard catalog has no array_* entries, so isthmus'
`RexExpressionConverter` would fail with "Unable to convert call …" on every
array call. New `opensearch_array_functions.yaml` declares:

  * `make_array(any1, …)` → `list<any1>` (variadic, min: 0).
  * `array_length(list<any1>)` → `i64?`.
  * `array_slice(list<any1>, i64, i64)` → `list<any1>` (with i32 fallback).
  * `array_distinct(list<any1>)` → `list<any1>`.
  * `array_to_string(list<any1>, string)` → `string?` (with varchar fallback).

Loaded via `SimpleExtension.load("/opensearch_array_functions.yaml")` and
merged into the plugin's extension collection in
`DataFusionPlugin.loadSubstraitExtensions()`.

Substrait's call-conversion path (and DataFusion's signature matcher) is
strict about operand types in ways Calcite's PPL lowering doesn't naturally
satisfy. Three adapters bridge the gap:

  * `MakeArrayAdapter` — implements `ScalarFunctionAdapter` directly
    (not `AbstractNameMappingAdapter`). PPL's `ArrayFunctionImpl` infers
    `ARRAY<commonElementType>` for the call's return type but does NOT
    widen the individual operand types. So `array(1, 1.5)` produces a
    RexCall whose operands are `(INTEGER, DECIMAL(2,1))` but whose return
    type is `ARRAY<DOUBLE>`. Substrait's variadic-`any1` consistency
    validator throws an `AssertionError` in that case (not a recoverable
    exception — it fatally exits the search-thread JVM). The adapter
    extracts the call's component type and CASTs each non-matching
    operand to it before emission.
  * `ArrayToStringAdapter` — declares a local `array_to_string` op and
    name-maps `SqlLibraryOperators.ARRAY_JOIN` → it.
  * `ArraySliceAdapter` — passes the `ARRAY_SLICE` call through unchanged
    but coerces the index operands (positions 1, 2, optional 3) to
    `BIGINT`. PPL's parser types positive integer literals as
    `DECIMAL(20,0)`; DataFusion's `array_slice` signature accepts only
    integer indexes and refuses to coerce decimal arguments.

Two third-party dependencies that surfaced as fatal `NoClassDefFoundError`
during execution of array-returning calls:

  * `commons-text` to analytics-engine — Calcite's `SqlFunctions` class
    statically references `org.apache.commons.text.similarity.LevenshteinDistance`.
    Without it, any Calcite RelNode walk that touches `SqlFunctions.<clinit>`
    poisons the search-thread JVM.
  * `jackson-datatype-jsr310` to **arrow-flight-rpc** (the parent plugin
    that bundles `arrow-vector`). `arrow-vector`'s `JsonStringArrayList`
    eagerly registers `JavaTimeModule` on its ObjectMapper in `<clinit>`,
    so any reader of an Arrow `ListVector` (i.e. every array-returning
    DataFusion call flowing through analytics-engine) hits a fatal
    NoClassDefFoundError. The dep belongs on arrow-flight-rpc's classpath
    because that plugin defines arrow-vector's classloader; bundling it
    in analytics-backend-datafusion (the child plugin) is invisible to
    arrow-vector. Marked `compileOnly` here to avoid jar-hell with
    arrow-flight-rpc's `api` dependency.

  * Before: 1/60 (testArrayWithMix only — exercises an error path that
    fails before the ARRAY capability lookup).
  * After:  9/60.
    Newly passing: testArray, testArrayWithString, testArrayLength,
    testMvjoinWithStringArray, testMvjoinWithStringifiedNumbers,
    testMvjoinWithMixedStringValues, testMvjoinWithStringBooleans,
    testMvjoinWithSpecialDelimiters, testMvjoinWithArrayFromRealFields,
    testMvjoinWithMultipleRealFields.

The remaining 51 failures fall into three buckets:

  * 50 — out-of-scope S1+ functions (`mvfind`, `mvzip`, `reduce`, `transform`,
    `forall`, `filter`, `exists`, `ITEM`). These are PPL UDFs without direct
    DataFusion equivalents and need either lambda-substrait wiring or
    custom UDF registration on the Rust side.
  * 5  — `testMvindexRange*` family. PPL's `mvindex(arr, from, to)` lowers
    to `ARRAY_SLICE(arr, from+1, to+1)` (1-based shift) but the lowering
    is missing the +1, so DataFusion's 1-based array_slice returns a
    window shifted by one. Fix belongs in the SQL plugin's PPL→Calcite
    lowering layer.
  * 1  — `testMvindexRangeMixed` JSON formatting mismatch (test code
    expects bare `[a,b,c]` but the response is `\"[\\\"a\\\",\\\"b\\\",\\\"c\\\"]\"`).

Signed-off-by: Kai Huang <ahkcs@amazon.com>

* [Analytics Backend / DataFusion] Fix ARRAY_SLICE 0-based-(start, length) → 1-based-(start, end) for DataFusion

Calcite's `SqlLibraryOperators.ARRAY_SLICE` is the Spark / Hive flavor —
0-based start, third arg is the length-of-elements to take. PPL's
`MVIndexFunctionImp.resolveRange` (in the SQL plugin) emits this form,
e.g. `mvindex(arr=[1..5], 1, 3)` → `ARRAY_SLICE(arr, 1, 3)` meaning
"start at 0-based position 1, take 3 elements" → expected `[2, 3, 4]`.

DataFusion's native `array_slice` is the Postgres / Snowflake flavor —
1-based start, third arg is the inclusive end-index. So the same call
`array_slice(arr, 1, 3)` returns elements at 1-based positions 1..3 →
`[1, 2, 3]`. Off-by-one across every `mvindex` range query.

Convert the operands in the adapter rather than the SQL plugin's PPL
lowering, because the lowering's existing semantics are correct for
Calcite's local executor (used by every non-analytics path); the bug is
only in the bridge to DataFusion.

  start' = start + 1
  end'   = start + length    (== start + 1 + (length - 1))

`MVIndexFunctionImp` already normalizes negative indexes to non-negative
0-based positions before invoking ARRAY_SLICE (it uses `arrayLen + idx`),
so the arithmetic above applies uniformly.

Empirically: `mvindex(arr=[1..5], 1, 3)` now returns the correct values
`[2, 3, 4]` (was `[1, 2, 3]`); negative form `mvindex(arr, -3, -1)`
returns `[3, 4, 5]` (was `[2, 3]`); mixed `mvindex(arr, -4, 2)` returns
`[2, 3]` matching the PPL spec.

The 5 `testMvindexRange*` tests still don't pass on the IT, but for an
unrelated reason — array-typed result values are being returned as
JSON-stringified scalars (`"[2,3,4]"`) instead of typed arrays. That's a
response-formatting issue affecting every array-returning test (also
`testArray`, `testArrayWithString`) and lives in a different code path;
it'll be addressed separately.

Signed-off-by: Kai Huang <ahkcs@amazon.com>

* [Analytics Backend / DataFusion] Wire PPL ITEM (mvindex single-element) → DataFusion array_element

PPL's `mvindex(arr, N)` single-element form lowers (in `MVIndexFunctionImp.resolveSingleElement`)
to Calcite's `SqlStdOperatorTable.ITEM` operator with a 1-based index (already
converted from PPL's 0-based input). DataFusion's native single-element array
accessor is `array_element` (also 1-based), so a name-mapping adapter + yaml
extension entry are sufficient.

Templated shape:

  ScalarFunction.ITEM (SqlKind.ITEM)
    + STANDARD_PROJECT_OPS membership (returns the array's element type, which
      resolves through the existing FieldType.fromSqlTypeName → SUPPORTED_FIELD_TYPES
      capability lookup for non-array element types — array-of-array is rare in
      PPL and not exercised by the current test surface)
    + scalarFunctionAdapters() entry → ArrayElementAdapter
        ↳ rewrites SqlStdOperatorTable.ITEM to a locally-declared SqlFunction
          named "array_element"
        ↳ coerces the index operand to BIGINT (PPL's parser produces DECIMAL
          for positive integer literals; DataFusion's array_element rejects
          DECIMAL indexes, same as array_slice)
    + ADDITIONAL_SCALAR_SIGS bridge for the locally-declared op
    + opensearch_array_functions.yaml extension entry:
        array_element(list<any1>, i64) → any1?

# Pass-rate (CalciteArrayFunctionIT, force-routed)

  * Before this commit: 9/60.
  * After this commit:  12/60.
    Newly passing: testMvindexSingleElementPositive,
    testMvindexSingleElementNegative,
    testMvindexSingleElementNegativeMiddle.

The other 3 tests that hit the ITEM rejection (testMvfindWith*) are
multi-step queries where ITEM is one node in a tree that also includes
unrelated S1+ functions (mvfind/mvzip/etc.); they remain blocked by
the upstream functions, not by ITEM itself.

Signed-off-by: Kai Huang <ahkcs@amazon.com>

* [Analytics Engine] Carry array-typed cells through RowResponseCodec without JSON-stringifying

The row-oriented fragment-execution wire format (`FragmentExecutionResponse`,
used when arrow-flight streaming is disabled — every single-node test cluster
today) shipped each cell through OpenSearch's `writeGenericValue` /
`readGenericValue`, which preserves `List` values as `ArrayList<Object>`. On
the coordinator side, `RowResponseCodec.decode` then re-materialized the rows
into a `VectorSchemaRoot` for `Iterable<VectorSchemaRoot>`-style consumers.

Two bugs in that re-materialization were eating array values:

1. `inferArrowType` walked rows for the first non-null cell and matched
   against {Long, Integer, …, CharSequence, byte[], Number}. {@code List}
   wasn't in the chain, so it fell through to {@code break} and the
   fallback {@link ArrowType.Utf8} — every array column became a VARCHAR
   column.
2. `setVectorValue` for {@link VarCharVector} called {@code value.toString()}.
   For a {@code JsonStringArrayList} that returns the JSON form
   {@code "[2,3,4]"}, which then got serialized as a JSON string in the
   final response. Tests like {@code testMvindexRangePositive} saw their
   array result come back as a string `"[2,3,4]"` instead of an array
   `[2, 3, 4]`.

Fix:

* Replace {@code inferArrowType} with {@code inferField} that returns a
  full {@link Field}. For {@code List} cells, build a list field with the
  inner element type inferred from the first non-null element (with a
  fallback that scans later rows in case the first list is empty/all-null).
* Add a {@code ListVector} arm to {@code setVectorValue} that delegates to
  a new {@code writeListValue}. The writer bypasses {@link UnionListWriter}
  entirely — it writes directly to the list's offset / validity buffers and
  to the inner data vector via the inner vector's typed `setSafe`. The
  writer-based API requires per-element `ArrowBuf` allocations for varchar
  elements that are easy to leak or use-after-free; the direct path is
  simpler and avoids both classes of bug.

Plus a separate Arrow gotcha that surfaced once arrays started flowing
through correctly:

* {@code ListVector.getObject} for a {@code VarCharVector} child returns a
  {@code JsonStringArrayList} whose elements are Arrow's {@link Text} class,
  not Java {@link String}. {@code ExprValueUtils.fromObjectValue} doesn't
  recognize {@code Text} and threw "unsupported object class
  org.apache.arrow.vector.util.Text". {@code ArrowValues.toJavaValue} now
  mirrors its top-level VarChar branch for list cells: when a list value
  comes back from a {@code ListVector}, normalize each {@code Text} element
  to a {@link String} before handing the list upward.

  * Before: 12/60 (mvindex range tests still showed expected-vs-actual
    diff because `[2,3,4]` came back as a JSON string, not an array).
  * After:  26/60.

  Newly passing:
    testMvindexRangePositive, testMvindexRangeNegative, testMvindexRangeMixed,
    testMvindexRangeFirstThree, testMvindexRangeLastThree,
    testMvindexRangeSingleElement,
    testMvdedupWithDuplicates, testMvdedupWithAllDuplicates,
    testMvdedupWithNoDuplicates, testMvdedupWithStrings,
    testArrayWithString,
    testSplitWithSemicolonDelimiter, testSplitWithMultiCharDelimiter,
    testSplitWithEmptyDelimiter.

Signed-off-by: Kai Huang <ahkcs@amazon.com>

* [Analytics] Add SHA + LICENSE files for new bundled deps; spotless

The `dependencyLicenses` precommit task scans `licenses/` for a `<jar>.sha1`
sibling per bundled dependency. Two deps added in this PR were missing them:

  * `commons-text-1.11.0` in analytics-engine — needs sha1 + LICENSE +
    NOTICE (no shared `commons-text-*` license files yet in this plugin).
    Apache 2.0; LICENSE and NOTICE extracted from the released jar.
  * `jackson-datatype-jsr310-2.21.3` in arrow-flight-rpc — sha1 only.
    arrow-flight-rpc's `dependencyLicenses` already maps `jackson-.*` to
    the shared `jackson-LICENSE` / `jackson-NOTICE` files via
    `mapping from: /jackson-.*/, to: 'jackson'`, so no new license/notice
    files are needed.

Plus googleJavaFormat reflow on `ArraySliceAdapter` and `DataFusionPlugin`
that spotlessCheck flagged in precommit.

Verified `:plugins:arrow-flight-rpc:precommit`,
`:sandbox:plugins:analytics-engine:precommit`, and
`:sandbox:plugins:analytics-backend-datafusion:precommit` all succeed.

Addresses review feedback on #21554.

Signed-off-by: Kai Huang <ahkcs@amazon.com>

* [Analytics Engine] Map BigDecimal cells to FloatingPoint in row-codec inference

{@code RowResponseCodec.scalarArrowType} ordered its instanceof checks
{Long, Integer, Short, Byte, Double, Float, Boolean, CharSequence, byte[],
Number(fallback) → Int(64)}. BigDecimal extends {@link Number} but isn't any
of the typed scalar arms, so it fell through to the {@code Number} fallback
and got encoded as a 64-bit integer column — silently truncating fractional
digits.

This bites PPL flows whose common element type is {@code DECIMAL} (e.g.
{@code array(1, -1.5, 2, 1.0)} — the v2-side {@code ArrayImplementor.internalCast}
explicitly maps the DECIMAL target to BigDecimal cells). The element values
{@code -1.5} and {@code 1.0} round to {@code -1} and {@code 1} when forced
through Int(64), so the array reads back as {@code [1, -1, 2, 1]} instead of
{@code [1, -1.5, 2, 1.0]}.

Promote BigDecimal cells to FloatingPoint(DOUBLE) — same precision the v2
engine uses for decimal-typed PPL results, so behavior matches across both
execution paths. The list writer's {@code Float8Vector} arm already uses
{@code ((Number) element).doubleValue()}, which correctly extracts the
fractional value from a BigDecimal.

Signed-off-by: Kai Huang <ahkcs@amazon.com>

* [Analytics Backend / DataFusion] Onboard PPL mvzip via custom Rust UDF

PPL `mvzip(left, right [, sep])` element-wise zips two arrays into a list of
strings, joined per pair by a separator (default `,`). DataFusion has no
stdlib equivalent — `array_concat` is end-to-end concatenation, and Substrait's
lambda support is too thin for a transform/zip rewrite — so this onboards a
custom Rust ScalarUDF on the analytics-backend-datafusion plugin's session
context and wires the Java side to route to it.

Templated shape (extends the existing pattern from convert_tz):

  Rust side:
    udf::mvzip::MvzipUdf — Signature::user_defined; coerce_types pins the
      first two args to ListArray and the optional 3rd to Utf8; invoke_with_args
      iterates per row, takes min(len(left), len(right)) elements, stringifies
      each (matching `Objects.toString(elem, "")` for null elements), and
      builds a List<Utf8>. Defensive Null-element-type arm handles the empty
      array case before the SQL-plugin VARCHAR-default kicks in.
    Registered on each session context via udf::register_all alongside
    convert_tz. 7 unit tests cover the basic / custom-sep / truncation /
    null-element / null-array / empty-array / numeric-array shapes.

  Java side:
    ScalarFunction.MVZIP enum entry (SqlKind.OTHER_FUNCTION; resolves through
      identifier-name valueOf("MVZIP") since PPL's MVZipFunctionImpl registers
      under the function name "mvzip").
    MvzipAdapter — locally-declared SqlFunction("mvzip") + ADDITIONAL_SCALAR_SIGS
      bridge so isthmus emits a Substrait scalar function call with the exact
      name the Rust UDF is registered under.
    DataFusionAnalyticsBackendPlugin: ARRAY_RETURNING_PROJECT_OPS membership
      (returns ARRAY<VARCHAR>, registered against FieldType.ARRAY); adapter
      registration in scalarFunctionAdapters().
    opensearch_array_functions.yaml: two impls for arity-2 and arity-3.

  * Before: 28/60.
  * After:  34/60.

  Newly passing — all 5 testMvzip* variants:
    testMvzipBasic, testMvzipWithCustomDelimiter, testMvzipNested,
    testMvzipWithEmptyArray, testMvzipWithBothEmptyArrays.

  (Test count delta is +6 because the test class also exercises mvzip in 1
  other test under a different name, picked up by the same fix.)

This PR's run also picks up the SQL-plugin companion #5421 which defaults
empty `array()` to ARRAY<VARCHAR>. Without that companion the testMvzipWith*EmptyArray
variants would still fail — substrait would reject the input ARRAY<NULL>
type before reaching the UDF. The Rust UDF's Null-element arm exists as a
defensive backstop in case the call ever reaches it with a null-typed list.

Signed-off-by: Kai Huang <ahkcs@amazon.com>

* [Analytics Backend / DataFusion] Onboard PPL mvfind via custom Rust UDF

PPL `mvfind(arr, regex)` finds the 0-based index of the first array element
matching a regex pattern (Java `Matcher.find` substring-match semantics), or
NULL if no match. DataFusion has no stdlib equivalent, and rewriting in terms
of array_position requires per-element regex evaluation that's only
expressible with substrait lambda support — out of scope here. Onboards a
custom Rust ScalarUDF on the analytics-backend-datafusion plugin's session
context, mirroring the mvzip/convert_tz pattern.

Templated shape:

  Rust side:
    udf::mvfind::MvfindUdf — Signature::user_defined; coerce_types pins arg 0
      to a list type and arg 1 to Utf8; invoke_with_args walks each row and
      finds the first non-null element whose stringified form matches the
      regex via Rust's `regex` crate (`Regex::is_match` is unanchored, same
      as Java's `Matcher.find`). Scalar pattern operands compile once up
      front and surface invalid-regex errors at plan time (mirrors the SQL
      plugin's plan-time `tryCompileLiteralPattern`); column-valued patterns
      compile per row and yield NULL for invalid patterns. Supports list
      element types Utf8 / Int{8,16,32,64} / UInt{8,16,32,64} / Float{32,64}
      / Boolean / Null. 7 unit tests cover the basic-match / no-match /
      null-array / empty-array / null-element / numeric-array / unanchored
      shapes.
    Registered on each session context via udf::register_all alongside
    convert_tz and mvzip.

  Java side:
    ScalarFunction.MVFIND enum entry (SqlKind.OTHER_FUNCTION; resolves
      through identifier-name valueOf("MVFIND") since PPL's
      MVFindFunctionImpl registers under the function name "mvfind").
    MvfindAdapter — locally-declared SqlFunction("mvfind") +
      ADDITIONAL_SCALAR_SIGS bridge so isthmus emits a Substrait scalar
      function call with the exact name the Rust UDF is registered under.
    DataFusionAnalyticsBackendPlugin: STANDARD_PROJECT_OPS membership
      (returns INTEGER, registered against the existing scalar
      SUPPORTED_FIELD_TYPES); adapter registration in
      scalarFunctionAdapters().
    opensearch_array_functions.yaml: arity-2 impl returning `i32?`.

  * Before: 34/60.
  * After:  42/60.

  Newly passing — 8 of 9 testMvfind* variants:
    testMvfindWithMatch, testMvfindWithFirstMatch, testMvfindWithMultipleMatches,
    testMvfindWithNoMatch, testMvfindWithEmptyArray, testMvfindWithNumericArray,
    testMvfindWithCaseInsensitive, testMvfindWithComplexRegex.

  Remaining mvfind failure:
    testMvfindWithDynamicRegex — fails with "Unable to convert call
    CONCAT(string, string)" because the test computes the pattern via
    `concat('ban', '.*')` and substrait can't bind the CONCAT call. This is a
    separate analytics-engine CONCAT type-conversion issue, not mvfind-specific.

Signed-off-by: Kai Huang <ahkcs@amazon.com>

* [Analytics Backend / DataFusion] Onboard PPL mvappend via custom Rust UDF

PPL `mvappend(arg1, arg2, …)` flattens a mixed list of array and scalar
arguments into one array, dropping null arguments and null elements within
array arguments. DataFusion's `array_concat` is the closest stdlib match but
only accepts arrays (not mixed array+scalar) and preserves nulls — different
semantics. Onboards as a custom Rust ScalarUDF on the analytics-backend-datafusion
plugin's session context, mirroring the mvzip / mvfind pattern.

Templated shape:

  Rust side:
    udf::mvappend::MvappendUdf — Signature::user_defined; per-row walk over
      operands, skipping NULL args and NULL elements inside array args, with
      explicit Arrow type arms for {Int8/16/32/64, UInt8/16/32/64,
      Float32/64, Boolean, Utf8/LargeUtf8/Utf8View}. The string arms output
      List<Utf8> or List<Utf8View> depending on the inferred element type so
      the result schema matches what `return_type` declared (DataFusion's
      execution-time schema check rejects mismatches). Defensive Null
      element-type arm covers the empty-array shape. 6 unit tests.
    Registered on each session context via udf::register_all.

  Java side:
    ScalarFunction.MVAPPEND enum entry (SqlKind.OTHER_FUNCTION; resolves
      through identifier-name valueOf("MVAPPEND")).
    MvappendAdapter — locally-declared SqlFunction("mvappend") +
      ADDITIONAL_SCALAR_SIGS bridge. Casts every scalar operand to the
      call's array component type and every array operand to
      ARRAY<componentType> before substrait emission, so the UDF sees a
      single uniform element type across all positions.
    DataFusionAnalyticsBackendPlugin: ARRAY_RETURNING_PROJECT_OPS membership
      (returns ARRAY<commonType>); adapter registration in
      scalarFunctionAdapters().
    opensearch_array_functions.yaml: variadic min:1 entry with `list<any1?>`
      return type.

  * Before: 0/15.
  * After:  6/15.

  Newly passing:
    testMvappendWithMultipleElements, testMvappendWithSingleElement,
    testMvappendWithArrayFlattening, testMvappendWithStringValues,
    testMvappendWithNestedArrays, testMvappendWithRealFields.

  * 8 tests fail with "Unable to convert the type ANY". Root cause is
    PPL's MVAppendFunctionImpl.updateMostGeneralType using strict
    Object.equals on each pair of operand types, returning Calcite's
    ANY type when any two don't match — including when they only differ
    in nullability tag (a literal 3 is INTEGER NOT NULL but the
    component type of `array(1, 2)` is INTEGER NULLABLE). Substrait
    can't serialize ANY. The fix belongs in the SQL plugin's
    MVAppendFunctionImpl (use typeFactory.leastRestrictive instead of
    Object.equals) and isn't addressed here.
  * testMvappendInWhereClause — uses `where array_length(combined) = 2`
    which the analytics-engine planner rejects with "No backend can
    evaluate filter predicate [EQUALS] on fields [combined:ARRAY]".
    Filter-side capability gap unrelated to mvappend.
  * testMvappendWithComplexExpression — fails substrait conversion on
    a nested mvappend call ("Unable to convert call mvappend(list, …)"),
    likely the same nullability widening pattern flowing through nested
    calls. Same upstream fix applies.

  Unchanged at 43/60 — mvappend isn't exercised there.

Signed-off-by: Kai Huang <ahkcs@amazon.com>

* [Analytics Backend / DataFusion] Reshape mvappend operands as uniform lists; add Decimal128 element support

Two follow-ons to the initial mvappend onboarding (40b2161), both surfaced
once the SQL companion #5424 (`MVAppendFunctionImpl.leastRestrictive`) let
homogeneous-type calls reach substrait conversion.

# Uniform-list operand reshape

Substrait's variadic-`any1` argument shape requires every operand at the same
variadic position to share a type. PPL's `mvappend(arg, …)` accepts a mix of
bare scalars and arrays, which substrait's signature matcher rejected with
`Unable to convert call mvappend(list<i32?>, i32?, i32?)`.

`MvappendAdapter` now wraps each scalar operand in a singleton
`make_array(scalar)` call (using the locally-declared `MakeArrayAdapter.LOCAL_MAKE_ARRAY_OP`)
so by the time the substrait converter sees the operands they're uniformly
`list<componentType>`. The yaml impl was correspondingly tightened from
`args: [{ value: any1 }] variadic` to `args: [{ value: list<any1?> }] variadic`.

Rust UDF (`udf::mvappend`) keeps its scalar-handling branch intact as a
defensive fallback, but in practice every operand it sees is a list now.

# Decimal128 element type

Calcite's leastRestrictive widening on INT + DECIMAL produces DECIMAL(p, s)
which substrait converts to Decimal128(p, s); the Java adapter casts every
operand's element type to that. The Rust UDF needed an explicit
`DataType::Decimal128(p, s)` branch — Decimal128Builder requires
`.with_precision_and_scale(p, s)` configuration before use, and Decimal128Array
elements are read via the `i128`-valued `value(i)` accessor (not via the
generic `build!` macro).

# Pass-rate (CalciteMVAppendFunctionIT, force-routed, with companion #5424 applied)

  * Before this commit: 6/15 (initial mvappend onboarding).
  * After this commit:  10/15.

  Newly passing:
    testMvappendWithMixedArrayAndScalar (uniform-list reshape),
    testMvappendWithComplexExpression (uniform-list reshape),
    testMvappendWithIntAndDouble (Decimal128 element),
    testMvappendWithNumericArrays (Decimal128 element).

  Remaining 5 failures:
    * testMvappendWithMixedTypes / WithFieldsAndLiterals / WithEmptyArray /
      WithNull — call legitimately widens to ARRAY<ANY> because operands
      contain pairs of types with no common widened type (INT + VARCHAR).
      The Calcite engine handles ANY via Object generic dispatch; substrait
      can't encode it. Out of scope without changing PPL UDF semantics.
    * testMvappendInWhereClause — uses `where array_length(combined) = 2`
      which the analytics-engine planner rejects with "No backend can
      evaluate filter predicate [EQUALS] on fields [combined:ARRAY]".
      Filter-side capability gap unrelated to mvappend.

Signed-off-by: Kai Huang <ahkcs@amazon.com>

* [Analytics Backend / DataFusion] Register UDFs on FFM-created session contexts

create_session_context (the Rust-side builder behind df_create_session_context)
built a fresh DataFusion SessionContext but never called udf::register_all on
it. Every fragment query routed through df_execute_with_context reused that
handle's ctx via query_executor::execute_with_context, so substrait function
references to mvappend / mvfind / mvzip / convert_tz failed planning with
"This feature is not implemented: Unsupported function name". The matching
register_all call exists in execute_query / local_executor / indexed_executor
— this just brings the FFM session-context path to parity.

Verified: CalciteMVAppendFunctionIT against the analytics-engine route now
passes 10/15 (was 0/15) with the SQL companion #5424 widening fix applied.
The remaining 5 are pre-existing ARRAY<ANY>/UNKNOWN substrait-encoding gaps
(heterogeneous mvappend signatures, empty-array default, filter-on-array
predicate) tracked in this PR's "What's left" section.

Signed-off-by: Kai Huang <ahkcs@amazon.com>

* [Analytics Backend / DataFusion] Don't let substrait AssertionError kill the cluster

Substrait's plan validators (VariadicParameterConsistencyValidator,
RelOptUtil.eq via Litmus.THROW, etc.) throw AssertionError directly via
explicit `throw new AssertionError(...)` rather than via the `assert`
keyword, so the JVM -da flag doesn't gate them. When a malformed plan
triggers one inside a search-thread call to SubstraitRelVisitor.apply,
the AssertionError propagates uncaught up the analytics-engine fragment
handler stack, OpenSearchUncaughtExceptionHandler classifies it as fatal,
and the entire cluster JVM exits.

Wrap the visitor.apply call in a narrow try/catch that re-raises the
AssertionError as IllegalStateException with the original message and
cause preserved. The analytics-engine error path already buckets
IllegalStateException at the fragment boundary into a normal HTTP 500
response — the cluster stays up and the failure shows in the per-query
report instead.

This came up while diagnosing CalciteMVAppendFunctionIT failures: malformed
ARRAY<ANY> plans were taking down the cluster mid-test instead of producing
per-test failures, masking the underlying substrait conversion error.

Signed-off-by: Kai Huang <ahkcs@amazon.com>

* [QA] Add ArrayFunctionIT + MVAppendFunctionIT for analytics-engine REST path

Self-contained QA ITs in sandbox/qa/analytics-engine-rest exercising the
PPL collection functions onboarded in this PR through POST /_analytics/ppl
against a parquet-backed `calcs` dataset, no SQL plugin checkout required.

ArrayFunctionIT (22 tests):
  - array constructor (mixed-numeric BigDecimal → Double promotion + int+string)
  - array_length
  - mvindex range (array_slice — 0-based-(start, length) → 1-based-(start, end))
  - mvindex single (array_element via ITEM rename)
  - mvdedup (array_distinct)
  - mvjoin (array_to_string rename)
  - mvzip (Rust UDF, default + custom delimiter + nested)
  - mvfind (Rust UDF, match / no-match / dynamic regex via concat() Sig bridge)
  - split (returns array)

MVAppendFunctionIT (6 tests):
  - uniform-typed scalar variadic (multiple, single, string)
  - array operands (flattening, nested string arrays)
  - VARCHAR field references via real calcs row

Tests gated on SQL companion #5424 (testMvappendWith{IntAndDouble,
MixedArrayAndScalar, NumericArrays, ComplexExpression}) are intentionally
absent — they fail with "Unable to convert the type ANY" until
MVAppendFunctionImpl's leastRestrictive widening + DECIMAL→DOUBLE
promotion + operand pre-cast is published as unified-query-core. A
top-of-class block lists them with a pointer back to #5424.

Lambda-based functions (transform, mvmap, reduce, forall, exists, filter)
and empty-array operands are absent for the architectural reasons in this
PR's "What's left" section: substrait extension YAML doesn't support
declaring func<…> lambda-typed args, and array() defaults to ARRAY<UNKNOWN>
which substrait can't encode without #5421.

Local verification (per `docs/dev/ppl-analytics-engine-routing.md` SOP):
- :sandbox:qa:analytics-engine-rest:integTest --tests "*ArrayFunctionIT" — 22/22
- :sandbox:qa:analytics-engine-rest:integTest --tests "*MVAppendFunctionIT" — 6/6
- :check -p sandbox — all 718 tasks green

Signed-off-by: Kai Huang <ahkcs@amazon.com>

---------

Signed-off-by: Kai Huang <ahkcs@amazon.com>
Bukhtawar pushed a commit to Bukhtawar/OpenSearch that referenced this pull request May 10, 2026
…/regexp_replace() functions (opensearch-project#21527)

Onboards the PPL `replace` command and the `replace()` / `regexp_replace()`
eval functions to the analytics-engine route by mapping their two Calcite
lowering targets — `SqlStdOperatorTable.REPLACE` and
`SqlLibraryOperators.REGEXP_REPLACE_3` — through Substrait to DataFusion's
native `replace` and `regexp_replace` UDFs.

Same templated shape as the `fillnull` POC (opensearch-project#21472):

  ScalarFunction enum constant
    + STANDARD_PROJECT_OPS membership
    + opensearch_scalar_functions.yaml extension entry
    + ADDITIONAL_SCALAR_SIGS Calcite-op→Substrait-name bridge
    = onboarded to the analytics route.

Two scalar functions added: REPLACE (literal substring replace) and
REGEXP_REPLACE (regex replace). Both project-side only; the comparison
result of a replaced field is filtered via the existing EQUALS capability,
so no STANDARD_FILTER_OPS additions are needed.

PPL's wildcard `replace` form lowers via `WildcardUtils.convertWildcardPatternToRegex()`
to a Java-`Pattern`-compatible regex. Two flavors of Java syntax need
translation before substrait serialization, because DataFusion uses Rust's
`regex` crate which has different parsing rules:

  * `\Q…\E` quoted-literal blocks — Rust rejects `\Q` as an unrecognized
    escape sequence. The adapter expands each block to per-character
    escaped literals (semantics-preserving).
  * `$N` numeric backreferences in the replacement — Rust's replacement
    parser is identifier-greedy, so `$1_$2` is parsed as a reference to
    group named `1_` followed by `$2` (Java parses it as group 1 + literal
    underscore + group 2). The adapter wraps every numeric backreference
    in braces (`${N}`) for unambiguous Rust parsing.

Both transforms are in `RegexpReplaceAdapter` and registered against
`ScalarFunction.REGEXP_REPLACE` in `scalarFunctionAdapters()`. Calls
without `\Q` in the pattern AND without bare `$N` in the replacement pass
through unchanged.

  * `RegexpReplaceAdapterTests` — 19/19 (unquote: 9, brace: 7, dual-rewrite
    integration: 3).
  * `ReplaceCommandIT` (new self-contained QA IT, calcs dataset) — 10/10.
    Covers literal command (single + multi-pair = nested REPLACE), wildcard
    command (prefix + suffix), `replace()` and `regexp_replace()` in eval,
    full-row content checks, no-match passthrough, multi-field IN clause.
  * SQL plugin's `CalciteReplaceCommandIT` force-routed through the
    analytics-engine route via `-Dtests.analytics.{force_routing,parquet_indices}=true`
    — 21/21 in both the direct suite and the `CalciteNoPushdownIT` re-run.
    (Companion SQL plugin PR opensearch-project#5415 makes 4 column-order assertions and 1
    error-message assertion order-agnostic, mirroring the rename precedent
    from opensearch-project#5413.)

Unlike `fillnull`/`regex` where the bridge was a single one-line capability
addition, `replace`'s wildcard form exposes Java↔Rust regex syntax
divergence. The adapter is reusable for any future Calcite operator whose
PPL lowering goes through `WildcardUtils` (e.g. potential future patterns
in `like`-with-escape, custom regex lowerings).

Signed-off-by: Jialiang Liang <jiallian@amazon.com>
RyanL1997 added a commit to RyanL1997/OpenSearch that referenced this pull request May 11, 2026
…dge-only

Onboards the PPL `rex` command's `mode=sed` surface — the part that lowers to
standard Calcite library operators and bridges through Substrait to DataFusion's
native UDFs. Three sed sub-variants covered:

  * `rex field=f mode=sed "s/old/new/"` (no flags) → SqlLibraryOperators.REGEXP_REPLACE_3
    (already mapped via the PPL `replace` onboarding from opensearch-project#21527 — no-op here).

  * `rex field=f mode=sed "s/old/new/g"` / `/i` / `/gi` → SqlLibraryOperators.REGEXP_REPLACE_PG_4
    (4-arg with flags string). New bridge in this PR. DataFusion's regexp_replace
    natively accepts 4-arg `(str, pat, repl, flags)` per its substrait UDF binding.

  * `rex field=f mode=sed "y/from/to/"` (transliteration) → SqlLibraryOperators.TRANSLATE3.
    New bridge in this PR. Resolves to DataFusion's `translate` UDF
    (datafusion-functions/src/unicode/translate.rs).

The 4-arg `REGEXP_REPLACE_PG_4` carries the same Java-regex syntax baggage as the
3-arg form: `\Q…\E` quoted-literal blocks (Rust regex rejects them) and bare `$N`
backreferences in the replacement (Rust's identifier-greedy parser
mis-resolves them). RegexpReplaceAdapter, introduced for the 3-arg form in
and replacement at position 2 in both signatures — the rewrite logic doesn't
change. Operands beyond position 2 (the flags string in the 4-arg form) pass
through verbatim. Two new RegexpReplaceAdapterTests cover the 4-arg path.

`TRANSLATE3` doesn't need an adapter — its arguments are character classes, not
regex syntax.

  * Rex extract mode (`rex field=f "(?<g>...)"`) — uses the SQL plugin's custom
    Java UDFs `REX_EXTRACT`, `REX_EXTRACT_MULTI`, `REX_OFFSET`, which have no
    native DataFusion equivalent. Slated for a follow-up PR that adds Rust-side
    UDF implementations, similar to the convert_tz precedent (opensearch-project#21476).

  * Sed with occurrence flag (`s/.../.../<N>`) — emits 5-arg
    `REGEXP_REPLACE_5`, which DataFusion's native `regexp_replace` does not
    support (max 4 args). Also Part 2.

  * `RegexpReplaceAdapterTests` — 21/21 (19 from opensearch-project#21527 + 2 new for the 4-arg path).
  * `RexCommandIT` (new self-contained QA IT, calcs dataset) — 9/9. Covers all sed
    sub-variants: literal (no flags), `/g` global, `/i` case-insensitive, `/gi`
    combined, backreferences via `$N`, transliteration `y/from/to/` and
    no-match passthrough.
  * `./gradlew check -p sandbox -Dsandbox.enabled=true` — green.

The unified-path NPE caused by a missing PPL_REX_MAX_MATCH_LIMIT default is fixed
in opensearch-project/sql#5418 — required for any rex query (sed or extract) to
reach the planner via /_analytics/ppl. This PR's Test results assume opensearch-project#5418 is
applied. Pre-fix: every query NPEs in `AstBuilder.visitRexCommand`. Post-fix:
9/9 RexCommandIT pass.

Signed-off-by: Jialiang Liang <jiallian@amazon.com>
RyanL1997 added a commit to RyanL1997/OpenSearch that referenced this pull request May 14, 2026
…dge-only

Onboards the PPL `rex` command's `mode=sed` surface — the part that lowers to
standard Calcite library operators and bridges through Substrait to DataFusion's
native UDFs. Three sed sub-variants covered:

  * `rex field=f mode=sed "s/old/new/"` (no flags) → SqlLibraryOperators.REGEXP_REPLACE_3
    (already mapped via the PPL `replace` onboarding from opensearch-project#21527 — no-op here).

  * `rex field=f mode=sed "s/old/new/g"` / `/i` / `/gi` → SqlLibraryOperators.REGEXP_REPLACE_PG_4
    (4-arg with flags string). New bridge in this PR. DataFusion's regexp_replace
    natively accepts 4-arg `(str, pat, repl, flags)` per its substrait UDF binding.

  * `rex field=f mode=sed "y/from/to/"` (transliteration) → SqlLibraryOperators.TRANSLATE3.
    New bridge in this PR. Resolves to DataFusion's `translate` UDF
    (datafusion-functions/src/unicode/translate.rs).

The 4-arg `REGEXP_REPLACE_PG_4` carries the same Java-regex syntax baggage as the
3-arg form: `\Q…\E` quoted-literal blocks (Rust regex rejects them) and bare `$N`
backreferences in the replacement (Rust's identifier-greedy parser
mis-resolves them). RegexpReplaceAdapter, introduced for the 3-arg form in
and replacement at position 2 in both signatures — the rewrite logic doesn't
change. Operands beyond position 2 (the flags string in the 4-arg form) pass
through verbatim. Two new RegexpReplaceAdapterTests cover the 4-arg path.

`TRANSLATE3` doesn't need an adapter — its arguments are character classes, not
regex syntax.

  * Rex extract mode (`rex field=f "(?<g>...)"`) — uses the SQL plugin's custom
    Java UDFs `REX_EXTRACT`, `REX_EXTRACT_MULTI`, `REX_OFFSET`, which have no
    native DataFusion equivalent. Slated for a follow-up PR that adds Rust-side
    UDF implementations, similar to the convert_tz precedent (opensearch-project#21476).

  * Sed with occurrence flag (`s/.../.../<N>`) — emits 5-arg
    `REGEXP_REPLACE_5`, which DataFusion's native `regexp_replace` does not
    support (max 4 args). Also Part 2.

  * `RegexpReplaceAdapterTests` — 21/21 (19 from opensearch-project#21527 + 2 new for the 4-arg path).
  * `RexCommandIT` (new self-contained QA IT, calcs dataset) — 9/9. Covers all sed
    sub-variants: literal (no flags), `/g` global, `/i` case-insensitive, `/gi`
    combined, backreferences via `$N`, transliteration `y/from/to/` and
    no-match passthrough.
  * `./gradlew check -p sandbox -Dsandbox.enabled=true` — green.

The unified-path NPE caused by a missing PPL_REX_MAX_MATCH_LIMIT default is fixed
in opensearch-project/sql#5418 — required for any rex query (sed or extract) to
reach the planner via /_analytics/ppl. This PR's Test results assume opensearch-project#5418 is
applied. Pre-fix: every query NPEs in `AstBuilder.visitRexCommand`. Post-fix:
9/9 RexCommandIT pass.

Signed-off-by: Jialiang Liang <jiallian@amazon.com>
RyanL1997 added a commit to RyanL1997/OpenSearch that referenced this pull request May 14, 2026
…dge-only

Onboards the PPL `rex` command's `mode=sed` surface — the part that lowers to
standard Calcite library operators and bridges through Substrait to DataFusion's
native UDFs. Three sed sub-variants covered:

  * `rex field=f mode=sed "s/old/new/"` (no flags) → SqlLibraryOperators.REGEXP_REPLACE_3
    (already mapped via the PPL `replace` onboarding from opensearch-project#21527 — no-op here).

  * `rex field=f mode=sed "s/old/new/g"` / `/i` / `/gi` → SqlLibraryOperators.REGEXP_REPLACE_PG_4
    (4-arg with flags string). New bridge in this PR. DataFusion's regexp_replace
    natively accepts 4-arg `(str, pat, repl, flags)` per its substrait UDF binding.

  * `rex field=f mode=sed "y/from/to/"` (transliteration) → SqlLibraryOperators.TRANSLATE3.
    New bridge in this PR. Resolves to DataFusion's `translate` UDF
    (datafusion-functions/src/unicode/translate.rs).

The 4-arg `REGEXP_REPLACE_PG_4` carries the same Java-regex syntax baggage as the
3-arg form: `\Q…\E` quoted-literal blocks (Rust regex rejects them) and bare `$N`
backreferences in the replacement (Rust's identifier-greedy parser
mis-resolves them). RegexpReplaceAdapter, introduced for the 3-arg form in
and replacement at position 2 in both signatures — the rewrite logic doesn't
change. Operands beyond position 2 (the flags string in the 4-arg form) pass
through verbatim. Two new RegexpReplaceAdapterTests cover the 4-arg path.

`TRANSLATE3` doesn't need an adapter — its arguments are character classes, not
regex syntax.

  * Rex extract mode (`rex field=f "(?<g>...)"`) — uses the SQL plugin's custom
    Java UDFs `REX_EXTRACT`, `REX_EXTRACT_MULTI`, `REX_OFFSET`, which have no
    native DataFusion equivalent. Slated for a follow-up PR that adds Rust-side
    UDF implementations, similar to the convert_tz precedent (opensearch-project#21476).

  * Sed with occurrence flag (`s/.../.../<N>`) — emits 5-arg
    `REGEXP_REPLACE_5`, which DataFusion's native `regexp_replace` does not
    support (max 4 args). Also Part 2.

  * `RegexpReplaceAdapterTests` — 21/21 (19 from opensearch-project#21527 + 2 new for the 4-arg path).
  * `RexCommandIT` (new self-contained QA IT, calcs dataset) — 9/9. Covers all sed
    sub-variants: literal (no flags), `/g` global, `/i` case-insensitive, `/gi`
    combined, backreferences via `$N`, transliteration `y/from/to/` and
    no-match passthrough.
  * `./gradlew check -p sandbox -Dsandbox.enabled=true` — green.

The unified-path NPE caused by a missing PPL_REX_MAX_MATCH_LIMIT default is fixed
in opensearch-project/sql#5418 — required for any rex query (sed or extract) to
reach the planner via /_analytics/ppl. This PR's Test results assume opensearch-project#5418 is
applied. Pre-fix: every query NPEs in `AstBuilder.visitRexCommand`. Post-fix:
9/9 RexCommandIT pass.

Signed-off-by: Jialiang Liang <jiallian@amazon.com>
mch2 pushed a commit that referenced this pull request May 14, 2026
* [Analytics Backend / DataFusion] Wire PPL rex sed-mode (Part 1) — bridge-only

Onboards the PPL `rex` command's `mode=sed` surface — the part that lowers to
standard Calcite library operators and bridges through Substrait to DataFusion's
native UDFs. Three sed sub-variants covered:

  * `rex field=f mode=sed "s/old/new/"` (no flags) → SqlLibraryOperators.REGEXP_REPLACE_3
    (already mapped via the PPL `replace` onboarding from #21527 — no-op here).

  * `rex field=f mode=sed "s/old/new/g"` / `/i` / `/gi` → SqlLibraryOperators.REGEXP_REPLACE_PG_4
    (4-arg with flags string). New bridge in this PR. DataFusion's regexp_replace
    natively accepts 4-arg `(str, pat, repl, flags)` per its substrait UDF binding.

  * `rex field=f mode=sed "y/from/to/"` (transliteration) → SqlLibraryOperators.TRANSLATE3.
    New bridge in this PR. Resolves to DataFusion's `translate` UDF
    (datafusion-functions/src/unicode/translate.rs).

The 4-arg `REGEXP_REPLACE_PG_4` carries the same Java-regex syntax baggage as the
3-arg form: `\Q…\E` quoted-literal blocks (Rust regex rejects them) and bare `$N`
backreferences in the replacement (Rust's identifier-greedy parser
mis-resolves them). RegexpReplaceAdapter, introduced for the 3-arg form in
and replacement at position 2 in both signatures — the rewrite logic doesn't
change. Operands beyond position 2 (the flags string in the 4-arg form) pass
through verbatim. Two new RegexpReplaceAdapterTests cover the 4-arg path.

`TRANSLATE3` doesn't need an adapter — its arguments are character classes, not
regex syntax.

  * Rex extract mode (`rex field=f "(?<g>...)"`) — uses the SQL plugin's custom
    Java UDFs `REX_EXTRACT`, `REX_EXTRACT_MULTI`, `REX_OFFSET`, which have no
    native DataFusion equivalent. Slated for a follow-up PR that adds Rust-side
    UDF implementations, similar to the convert_tz precedent (#21476).

  * Sed with occurrence flag (`s/.../.../<N>`) — emits 5-arg
    `REGEXP_REPLACE_5`, which DataFusion's native `regexp_replace` does not
    support (max 4 args). Also Part 2.

  * `RegexpReplaceAdapterTests` — 21/21 (19 from #21527 + 2 new for the 4-arg path).
  * `RexCommandIT` (new self-contained QA IT, calcs dataset) — 9/9. Covers all sed
    sub-variants: literal (no flags), `/g` global, `/i` case-insensitive, `/gi`
    combined, backreferences via `$N`, transliteration `y/from/to/` and
    no-match passthrough.
  * `./gradlew check -p sandbox -Dsandbox.enabled=true` — green.

The unified-path NPE caused by a missing PPL_REX_MAX_MATCH_LIMIT default is fixed
in opensearch-project/sql#5418 — required for any rex query (sed or extract) to
reach the planner via /_analytics/ppl. This PR's Test results assume #5418 is
applied. Pre-fix: every query NPEs in `AstBuilder.visitRexCommand`. Post-fix:
9/9 RexCommandIT pass.

Signed-off-by: Jialiang Liang <jiallian@amazon.com>

* [Analytics Backend / DataFusion] Wire PPL rex extract-mode (Part 2) — Rust UDFs + array result type

Completes the PPL `rex` onboarding started in Part 1 (#21550). The sed-mode forms
were already covered by bridges to existing Calcite/DataFusion operators. The
extract-mode form has no native DataFusion equivalent and needs three custom
Rust UDFs, three Java SqlOperator adapters, and a small handful of analytics-
framework / engine plumbing changes to model array result types end-to-end.

  * `rex_extract(input, pattern_lit, group_lit) -> varchar` — single named or
    numbered group capture. Compiles the regex once at plan time, runs per row.
  * `rex_extract_multi(input, pattern_lit, group_lit, max_match) -> list<varchar>`
    — multi-match. `max_match=0` means unbounded; otherwise caps the result at
    the requested element count. Returns NULL (not an empty list) when there
    are no matches, matching the SQL plugin's Java implementation.
  * `rex_offset(input, pattern_lit) -> varchar` — emits the named-group offsets
    formatted as `"name1=s1-e1&name2=s2-e2"`, alphabetically sorted; end is
    inclusive, matching the SQL plugin's `RexOffsetFunction.end - 1` convention.

Each UDF has 5 unit tests covering the contract above.

  * `RexExtractAdapter`, `RexExtractMultiAdapter`, `RexOffsetAdapter` — keyed on
    the SQL plugin's PPL builtin operator names (`REX_EXTRACT`,
    `REX_EXTRACT_MULTI`, `REX_OFFSET`) via the analytics-framework
    ScalarFunction enum. Each adapter rewrites the incoming RexCall to a local
    target SqlOperator (`LOCAL_REX_EXTRACT_OP`, etc.) that
    `DataFusionFragmentConvertor`'s `ADDITIONAL_SCALAR_SIGS` maps to the
    corresponding `rex_extract` / `rex_extract_multi` / `rex_offset` Substrait
    extension declared in `opensearch_scalar_functions.yaml`.

  * Pattern operands (and the group operand for the extract variants) are
    validated as RexLiterals at plan time. Column-valued patterns would force
    per-row regex compilation on the Rust side and are rejected with an
    IllegalArgumentException — same contract as the precedent set by
    RegexpReplaceAdapter in Part 1. `RexExtractAdapterTests` covers this.

  * `FieldType.ARRAY` enum value + `fromSqlTypeName(ARRAY) -> FieldType.ARRAY`
    in analytics-framework. Without this, `OpenSearchProjectRule.resolveScalar
    ViableBackends` returns `null` for any scalar with an array return type
    and the planner emits "No backend supports scalar function [REX_EXTRACT_
    MULTI] among [datafusion]". `REX_EXTRACT_MULTI`'s ProjectCapability.Scalar
    declaration is now `Set.of(FieldType.ARRAY)` rather than the broad scalar
    set used by every other op (UPPER, ABS, ...) — those genuinely don't return
    arrays.

  * `ListVector` handling in three call sites that previously triggered Arrow's
    `JsonStringArrayList.<clinit>`, which references `JavaTimeModule` from
    `jackson-datatype-jsr310` (not on the `arrow-flight-rpc` parent plugin's
    classloader). Bypassing `getObject()` and reading offset buffer + inner
    data vector directly:
      - `DatafusionResultStream.getFieldValue` (shard-side row materialization)
      - `ArrowValues.toJavaValue` (coordinator post-execution row reading)
      - `RowResponseCodec` (`inferArrowField` + `setVectorValue`) — the
        Object[]-row → Arrow VectorSchemaRoot wire codec needed an explicit
        list<utf8> Field with proper child Field, plus a ListVector setter
        using `startNewValue`/`endValue` + the inner VarCharVector's `setSafe`.

  * `RexCommandIT` extended from 9 sed tests to 16 — adds 7 extract-mode cases:
    single named group, multiple named groups in one row, missing-group
    NULL handling, multi-match capturing all, `max_match` cap, offset_field
    output, and no-match passthrough as NULL.

  * Rust UDFs — 15/15 unit tests (5 per UDF).
  * `RexExtractAdapterTests` — 4/4.
  * `RexCommandIT` — 16/16 (9 sed from Part 1 + 7 new extract).
  * `./gradlew check -p sandbox -Dsandbox.enabled=true` — green (678 tasks,
    all sandbox module unit tests + spotless + license + forbidden API).

Signed-off-by: Jialiang Liang <jiallian@amazon.com>

* [Analytics Backend / DataFusion] Onboard array_length scalar function (Part 3)

Wires Calcite's `SqlLibraryOperators.ARRAY_LENGTH` to DataFusion's native
`array_length`, completing the end-to-end story for PPL `rex` extract-mode
multi-match: queries can now size the list returned by `rex_extract_multi`
(`eval count = array_length(g)`).

  * `ScalarFunction.ARRAY_LENGTH` enum value (resolves via the `valueOf()`
    fallback on the Calcite operator name).
  * Registered in `STANDARD_PROJECT_OPS`. Returns `bigint`, so the existing
    `SUPPORTED_FIELD_TYPES` (numeric ∪ keyword ∪ date ∪ {BOOLEAN, TEXT})
    covers the capability lookup — no special-case needed.
  * `FunctionMappings.s(SqlLibraryOperators.ARRAY_LENGTH, "array_length")` in
    `DataFusionFragmentConvertor.ADDITIONAL_SCALAR_SIGS`. Library operators
    don't auto-resolve through the substrait default catalog — the same
    explicit pinning pattern used for `ILIKE`, `DATE_PART`, and the
    `REGEXP_REPLACE_*` family.
  * `array_length` extension declaration in `opensearch_scalar_functions.yaml`
    with `list<varchar<L1>>` → `i64` and `list<string>` → `i64` impls. Without
    a custom YAML extension that matches the actual list type, isthmus emits
    "Unable to convert call ARRAY_LENGTH(list<varchar<...>>)" for the
    `rex_extract_multi` output.

Lifts CalciteRexCommandIT (SQL plugin's standard rex IT class) through the
analytics-engine route from 14/18 → 17/18. The remaining failure
(testRexMaxMatchConfigurableLimit) is a unified-query architectural gap —
`UnifiedQueryContext` ignores cluster-setting overrides and uses the static
default — unrelated to rex or array_length.

Signed-off-by: Jialiang Liang <jiallian@amazon.com>

* Collapse array_length impls to single list<any1>

CI surfaced this on the post-rebase rex run:

  Duplicate key FunctionAnchor{urn=extension:org.opensearch:scalar_functions,
    key=array_length:list} (attempted merging values
    array_length:list and array_length:list)

The Part 3 commit declared two impls — `list<varchar<L1>>` and `list<string>`
— with the intent of covering both element-type families produced by
`rex_extract_multi`'s pair of impls. But substrait's compound function key
drops the inner parametric element type at the key level, so both impls
collapse to the same key `array_length:list`. The YAML loader rejects the
collision when the analytics-backend-datafusion plugin's
`SimpleExtension.ExtensionCollection` merges the file in.

Replace the two impls with a single `list<any1>` polymorphic impl. The
`any1` type variable matches any element type at planning, so a call site
that produces `list<varchar<L1>>` (rex_extract_multi varchar overload) and
a call site that produces `list<string>` (rex_extract_multi string
overload) both bind to the one impl. Net effect on planning is equivalent
and the duplicate-key collision goes away.

The duplicate didn't surface on the original rex CI run because the prior
PPL_REX_MAX_MATCH_LIMIT NPE failed every query at plan time before the
function-extension merge was reached. Once the mavenLocal pin fix landed
the prior commit and queries actually reached the planner, this older
latent collision was unmasked.

Signed-off-by: Jialiang Liang <jiallian@amazon.com>

---------

Signed-off-by: Jialiang Liang <jiallian@amazon.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants