Skip to content

Enable PPL eval string concat on the analytics-engine route via DataFusion CONCAT/CAST - #21498

Merged
mch2 merged 8 commits into
opensearch-project:mainfrom
ahkcs:pr/eval-poc
May 6, 2026
Merged

Enable PPL eval string concat on the analytics-engine route via DataFusion CONCAT/CAST#21498
mch2 merged 8 commits into
opensearch-project:mainfrom
ahkcs:pr/eval-poc

Conversation

@ahkcs

@ahkcs ahkcs commented May 5, 2026

Copy link
Copy Markdown
Contributor

Description

Drives the analytics-engine route to parity for PPL eval with string concatenation and CAST(... AS STRING). Pairs with the routing playbook (developed alongside the fillnull PR #21472) — same pattern, one new resolver helper, three new project capabilities, one Bucket-2 adapter, one QA IT.

Bucket classification

Surface Bucket Mechanism
'lit' + str_field, str + str S0 capability STANDARD_PROJECT_OPS += CONCAT
CAST(x AS STRING) (non-null source) S0 capability STANDARD_PROJECT_OPS += CAST
CAST(x AS STRING) (nullable source) S0 capability STANDARD_PROJECT_OPS += SAFE_CAST
Null-propagation through || S1 adapter ConcatFunctionAdapter rewrites ||(a,b)CASE WHEN IS_NULL(a) OR IS_NULL(b) THEN NULL ELSE ||(a,b) END
Symbolic-operator resolution (||) Planner fix ScalarFunction.fromSqlOperator(SqlOperator)

Failure modes addressed

PPL string + lowers to Calcite's SqlStdOperatorTable.CONCAT — a SqlBinaryOperator named \|\| with SqlKind.OTHER. Before this PR, both OpenSearchProjectRule and OpenSearchFilterRule failed to resolve it because:

  • ScalarFunction.fromSqlKind(OTHER) returns null (OTHER is shared by many ops).
  • instanceof SqlFunction is false for SqlBinaryOperator, so the SqlFunction-cast branch was skipped.
java.lang.IllegalStateException: No backend supports scalar function [null] among [datafusion]
  at OpenSearchProjectRule.annotateExpr(OpenSearchProjectRule.java:123)

After wiring CONCAT/CAST/SAFE_CAST into STANDARD_PROJECT_OPS, the next failure was DataFusion-side null semantics: 'Age: ' + CAST(null AS STRING) returned 'Age: ' instead of null. Calcite's \|\| follows SQL standard (any NULL → NULL); Substrait's default extension catalog documents the same; but DataFusion's substrait reader maps to its native concat() function which treats NULL as empty string. ConcatFunctionAdapter short-circuits via a CASE/IS_NULL wrapper.

Test results

Before After
CalciteEvalCommandIT (SQL-plugin v2-side, analytics-engine route) 0/4 4/4
EvalCommandIT (this PR, QA-side, POST /_analytics/ppl) n/a 4/4
FillNullCommandIT (regression check) 13/13 13/13
AppendCommandIT (regression check) passing passing
ScalarFunctionTests (new unit coverage) n/a 7/7
./gradlew check -p sandbox passing passing

Design trade-off worth flagging

ConcatFunctionAdapter uses a CASE/IS_NULL plan rewrite rather than a custom DataFusion null-propagating concat UDF. The rewrite is surgical, reuses the existing Substrait conversion path (CASE serializes to if_then, no new extension), and avoids cross-language work. The cost is a per-\|\|-call CASE wrapper that double-references each operand in IS_NULL checks — fine for RexInputRef/RexLiteral (the only shapes PPL emits for string concat today), proportional in nested concats ('A=' + str0 + ', B=' + str2 becomes three CASE-wrapped \|\| calls — each evaluating its direct operands twice, not the whole expression). A Bucket-3 UDF is the alternative if reviewers prefer; happy to move the adapter out if so.

Commit shape

# Commit Files Why split
1 [Analytics Framework] Resolve symbolic operators and add SAFE_CAST ScalarFunction.java, ScalarFunctionTests.java Generic resolver fix, useful even outside this PR (any future symbolic operator).
2 [Analytics Engine] Migrate rules and adapter dispatch to fromSqlOperator OpenSearchProjectRule, OpenSearchFilterRule, BackendPlanAdapter Mechanical migration of 3 call sites; behavior change only for previously-unresolvable operators.
3 [Analytics Backend / DataFusion] Wire CONCAT/CAST/SAFE_CAST + concat null adapter DataFusionAnalyticsBackendPlugin.java, ConcatFunctionAdapter.java Eval-specific capability + Bucket-2 adapter.
4 [QA] Add EvalCommandIT for the analytics-engine REST path EvalCommandIT.java QA-side IT in sandbox/qa/analytics-engine-rest, reuses calcs dataset.

Forward pattern

After this lands, future Bucket-1 PPL eval functions for which DataFusion has the substrait mapping are one-line additions to STANDARD_PROJECT_OPS plus an enum entry in ScalarFunction if missing — no resolver work needed.

By submitting this pull request

  • My code follows the OpenSearch style guidelines.
  • I have commented my code, particularly in hard-to-understand areas.
  • I have made corresponding changes to the documentation.
  • I have added tests that prove my fix is effective or that my feature works.
  • New and existing unit tests pass locally with my changes.
  • I have signed off my commit using DCO.

@ahkcs
ahkcs requested a review from a team as a code owner May 5, 2026 20:16
@github-actions

github-actions Bot commented May 5, 2026

Copy link
Copy Markdown
Contributor

PR Reviewer Guide 🔍

(Review updated until commit eb2121e)

Here are some key observations to aid the review process:

🧪 PR contains tests
🔒 No security concerns identified
📝 TODO sections

🔀 Multiple PR themes

Sub-PR theme: Add fromSqlOperatorWithFallback resolver and SAFE_CAST/CONCAT enum entries to ScalarFunction

Relevant files:

  • sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/ScalarFunction.java
  • sandbox/libs/analytics-framework/src/test/java/org/opensearch/analytics/spi/ScalarFunctionTests.java

Sub-PR theme: Add ConcatFunctionAdapter and register CONCAT/SAFE_CAST in DataFusion backend

Relevant files:

  • sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/ConcatFunctionAdapter.java
  • sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/DataFusionAnalyticsBackendPlugin.java
  • sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/ConcatFunctionAdapterTests.java

Sub-PR theme: Wire fromSqlOperatorWithFallback into planner rules and add eval IT

Relevant files:

  • sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/planner/dag/BackendPlanAdapter.java
  • sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/planner/rules/OpenSearchFilterRule.java
  • sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/planner/rules/OpenSearchProjectRule.java
  • sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/EvalCommandIT.java

⚡ Recommended focus areas for review

Static State Risk

The dataProvisioned static boolean field is not reset between test runs. If tests are run in isolation or in a different order, the flag may incorrectly indicate that data has already been provisioned when it hasn't. Consider using a @BeforeClass/@AfterClass pattern or a proper setup mechanism instead of a static boolean.

private static boolean dataProvisioned = false;

/**
 * Lazily provision the calcs dataset on first invocation. Must be called inside a test
 * method (not {@code setUp()}) — {@link org.opensearch.test.rest.OpenSearchRestTestCase}'s
 * static {@code client()} is not initialized until after {@code @BeforeClass}, but is
 * reliably available inside test bodies.
 */
private void ensureDataProvisioned() throws IOException {
    if (dataProvisioned == false) {
        DatasetProvisioner.provision(client(), DATASET);
        dataProvisioned = true;
    }
}
JSON Injection Risk

The escapeJson method (called in executePpl) is referenced but not defined in this file. If the implementation is insufficient, PPL query strings containing special characters (quotes, backslashes) could break the JSON payload or introduce injection-like issues. Verify that escapeJson properly handles all special characters.

request.setJsonEntity("{\"query\": \"" + escapeJson(ppl) + "\"}");
SqlKind Ambiguity

In fromSqlOperatorWithFallback, the fromSqlKind call may return an incorrect result for operators with SqlKind.OTHER or SqlKind.OTHER_FUNCTION if any enum constant is ever assigned those kinds (since fromSqlKind picks the first match). The existing testNoDuplicateSqlKindBindings test guards against duplicates for non-OTHER kinds, but the behavior for OTHER/OTHER_FUNCTION kinds should be explicitly documented or guarded in fromSqlOperatorWithFallback to avoid silent misresolution.

public static ScalarFunction fromSqlOperatorWithFallback(SqlOperator operator) {
    ScalarFunction byKind = fromSqlKind(operator.getKind());
    if (byKind != null) {
        return byKind;
    }
    ScalarFunction byReference = BY_REFERENCE_OPERATOR.get(operator);
    if (byReference != null) {
        return byReference;
    }
    try {
        return ScalarFunction.valueOf(operator.getName().toUpperCase(Locale.ROOT));
    } catch (IllegalArgumentException ignored) {
        return null;
    }
}
CASE Semantics

The CASE rewrite CASE WHEN IS_NULL(a) OR IS_NULL(b) THEN NULL ELSE ||(a,b) END duplicates the original || call in the ELSE branch. If the original RexCall is mutated or re-used elsewhere in the plan, sharing the same reference could cause issues. Consider whether a defensive copy of the original call is needed, or document the assumption that RexCall objects are immutable in this context.

return rexBuilder.makeCall(original.getType(), SqlStdOperatorTable.CASE, List.of(anyNull, nullLiteral, original));

@github-actions

github-actions Bot commented May 5, 2026

Copy link
Copy Markdown
Contributor

PR Code Suggestions ✨

Latest suggestions up to eb2121e

Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
Possible issue
Verify correct CASE expression construction API

The CASE operator in Calcite's RexBuilder.makeCall with SqlStdOperatorTable.CASE
expects operands in the form [condition1, result1, ..., else_result], but the
standard makeCall overload infers the return type from the operator rather than
using the explicitly passed type. Using
rexBuilder.makeCall(SqlStdOperatorTable.CASE, anyNull, nullLiteral, original)
without the explicit type may cause a type mismatch if the inferred type differs
from the original. Verify that the explicit-type overload makeCall(RelDataType,
SqlOperator, List) is the correct API for constructing a CASE expression, or use
rexBuilder.makeCall(SqlStdOperatorTable.CASE, anyNull, nullLiteral, original) and
confirm the return type is correctly inferred.

sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/ConcatFunctionAdapter.java [67]

 RexNode nullLiteral = rexBuilder.makeNullLiteral(original.getType());
-return rexBuilder.makeCall(original.getType(), SqlStdOperatorTable.CASE, List.of(anyNull, nullLiteral, original));
+return rexBuilder.makeCall(SqlStdOperatorTable.CASE, anyNull, nullLiteral, original);
Suggestion importance[1-10]: 4

__

Why: The suggestion asks to verify whether the explicit-type overload makeCall(RelDataType, SqlOperator, List<RexNode>) is correct, but the existing code already uses this overload intentionally to preserve the original CONCAT's return type. The 'improved_code' removes the explicit type, which could actually cause a type mismatch. This is more of a verification request than a clear improvement.

Low
General
Use thread-safe flag for test data provisioning

Using a non-volatile static boolean for dataProvisioned in a test class is not
thread-safe and can lead to race conditions if tests are run in parallel.
Additionally, a static field shared across test instances may not be reset between
test runs (e.g., when the test suite is re-run in the same JVM), causing the
provisioning step to be skipped on subsequent runs. Consider using a static
AtomicBoolean or a @BeforeClass-equivalent mechanism to ensure safe, idempotent
provisioning.

sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/EvalCommandIT.java [46]

-private static boolean dataProvisioned = false;
+private static final java.util.concurrent.atomic.AtomicBoolean dataProvisioned = new java.util.concurrent.atomic.AtomicBoolean(false);
Suggestion importance[1-10]: 4

__

Why: While using AtomicBoolean is a valid thread-safety improvement, integration tests in OpenSearch typically run sequentially in a single thread, making this a minor concern. The suggestion is valid but has low practical impact in this context.

Low
Fix non-atomic check-then-act provisioning pattern

If dataProvisioned is changed to AtomicBoolean, the check-then-act pattern here is
still not atomic and could result in double provisioning under concurrent access.
Use compareAndSet to atomically transition from false to true and only provision if
the transition succeeds.

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

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

__

Why: This suggestion is dependent on suggestion 2 (changing to AtomicBoolean) and addresses the check-then-act race condition. However, since integration tests typically run sequentially, the practical impact is minimal. The suggestion is logically sound but only relevant if suggestion 2 is also applied.

Low

Previous suggestions

Suggestions up to commit e88711f
CategorySuggestion                                                                                                                                    Impact
General
Fix thread-safety of static provisioning flag

Using a plain static boolean for dataProvisioned is not thread-safe. If tests run in
parallel, multiple threads could simultaneously observe false and each attempt to
provision the dataset, leading to race conditions or duplicate provisioning. Use a
volatile field or an AtomicBoolean to ensure safe publication across threads.

sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/EvalCommandIT.java [46]

-private static boolean dataProvisioned = false;
+private static volatile boolean dataProvisioned = false;
Suggestion importance[1-10]: 3

__

Why: Adding volatile to dataProvisioned is a valid thread-safety improvement, but integration tests in OpenSearch typically run single-threaded per class, making this a low-priority concern in practice.

Low
Use proper JSON serialization for request body

The PPL query string is embedded directly into a JSON string using string
concatenation with escapeJson. If escapeJson does not properly escape all special
characters (e.g., backslashes, control characters), this could produce malformed
JSON. Consider using a proper JSON serialization library (e.g., Jackson's
ObjectMapper) to construct the request body safely.

sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/EvalCommandIT.java [196-202]

 private Map<String, Object> executePpl(String ppl) throws IOException {
     ensureDataProvisioned();
     Request request = new Request("POST", "/_analytics/ppl");
-    request.setJsonEntity("{\"query\": \"" + escapeJson(ppl) + "\"}");
+    // Use a JSON library to safely serialize the query string
+    String body = "{\"query\": " + new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(ppl) + "}";
+    request.setJsonEntity(body);
     Response response = client().performRequest(request);
     return assertOkAndParse(response, "PPL: " + ppl);
 }
Suggestion importance[1-10]: 3

__

Why: While using a proper JSON serializer is generally safer, the code already uses escapeJson() which is presumably designed for this purpose. The suggestion introduces an inline new ObjectMapper() instantiation which is not idiomatic and may not be better than the existing approach.

Low
Possible issue
Verify CASE RexCall construction API compatibility

The CASE operator in Calcite uses a specific operand layout: [condition1, result1,
..., else]. With three operands [anyNull, nullLiteral, original], this correctly
maps to CASE WHEN anyNull THEN nullLiteral ELSE original END. However,
rexBuilder.makeCall(SqlStdOperatorTable.CASE, ...) without an explicit return type
may infer a type that differs from original.getType(). Passing the explicit return
type as the first argument (as done here) is correct, but verify that the overload
makeCall(RelDataType, SqlOperator, List) exists in the Calcite version used; some
versions only expose makeCall(SqlOperator, List).

sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/ConcatFunctionAdapter.java [66-67]

+RexNode nullLiteral = rexBuilder.makeNullLiteral(original.getType());
+return rexBuilder.makeCall(original.getType(), SqlStdOperatorTable.CASE, List.of(anyNull, nullLiteral, original));
 
-
Suggestion importance[1-10]: 2

__

Why: The existing_code and improved_code are identical, meaning no actual change is proposed. The suggestion only asks to verify API compatibility, which is a low-value check rather than a concrete fix.

Low
Suggestions up to commit e56f60d
CategorySuggestion                                                                                                                                    Impact
Possible issue
Ensure CASE result type is nullable

The CASE operator in Calcite's RexBuilder.makeCall with an explicit return type may
not correctly propagate nullability. The CASE expression WHEN anyNull THEN NULL ELSE
original should produce a nullable type, but if original.getType() is non-nullable,
the result type will incorrectly be non-nullable. The return type should be
explicitly made nullable to reflect that the CASE can return NULL.

sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/ConcatFunctionAdapter.java [66-67]

 RexNode nullLiteral = rexBuilder.makeNullLiteral(original.getType());
-return rexBuilder.makeCall(original.getType(), SqlStdOperatorTable.CASE, List.of(anyNull, nullLiteral, original));
+RelDataType nullableType = rexBuilder.getTypeFactory().createTypeWithNullability(original.getType(), true);
+return rexBuilder.makeCall(nullableType, SqlStdOperatorTable.CASE, List.of(anyNull, nullLiteral, original));
Suggestion importance[1-10]: 5

__

Why: This is a valid concern — if original.getType() is non-nullable, the CASE expression that can return NULL should have a nullable return type. The improved_code correctly adds explicit nullability to the result type, which could prevent type-system inconsistencies downstream in the Substrait conversion path.

Low
Fix non-thread-safe static provisioning flag

The dataProvisioned static flag is not thread-safe and is never reset between test
runs. If tests run in parallel or the test class is reused across JVM instances,
provisioning may be skipped or double-executed. Use a volatile modifier or an
AtomicBoolean to ensure correct visibility across threads.

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

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

__

Why: While the thread-safety concern is valid in general, integration tests in OpenSearch's test framework typically run single-threaded per class, making this a low-priority concern. The double-checked locking pattern suggested is correct but likely unnecessary in this context.

Low
General
Verify JSON escaping method availability and correctness

The escapeJson method is called but never defined in the visible code. If it is not
defined in a parent class, this will cause a compilation error. Additionally,
manually constructing JSON strings is fragile — special characters in ppl (e.g.
backslashes, quotes) could break the JSON even with escaping.

sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/EvalCommandIT.java [196-202]

 private Map<String, Object> executePpl(String ppl) throws IOException {
     ensureDataProvisioned();
     Request request = new Request("POST", "/_analytics/ppl");
+    // Use a proper JSON serializer to avoid injection issues
+    String jsonBody = "{\"query\": " + org.apache.lucene.util.TestUtil.randomRealisticUnicodeString(random()) + "}";
+    // Or rely on the inherited escapeJson if available in AnalyticsRestTestCase:
     request.setJsonEntity("{\"query\": \"" + escapeJson(ppl) + "\"}");
     Response response = client().performRequest(request);
     return assertOkAndParse(response, "PPL: " + ppl);
 }
Suggestion importance[1-10]: 1

__

Why: The improved_code is nonsensical — it introduces a random Unicode string as the JSON body, which would break the test entirely. The suggestion raises a valid question about escapeJson availability but the proposed fix is worse than the original code, making this suggestion unhelpful.

Low
Suggestions up to commit d6641af
CategorySuggestion                                                                                                                                    Impact
Possible issue
Ensure CASE return type is nullable

The CASE expression is constructed as CASE WHEN anyNull THEN nullLiteral ELSE
original END, but Calcite's CASE operator expects operands in the form [condition1,
value1, ..., else_value]. With only three operands [anyNull, nullLiteral, original],
this maps to a single-branch CASE which is correct, but the return type passed to
makeCall may conflict if original.getType() is non-nullable while nullLiteral is
nullable. Ensure the return type is explicitly nullable to avoid type validation
errors.

sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/ConcatFunctionAdapter.java [57-67]

 RexNode anyNull = rexBuilder.makeCall(SqlStdOperatorTable.IS_NULL, operands.get(0));
 for (int i = 1; i < operands.size(); i++) {
     anyNull = rexBuilder.makeCall(
         SqlStdOperatorTable.OR,
         anyNull,
         rexBuilder.makeCall(SqlStdOperatorTable.IS_NULL, operands.get(i))
     );
 }
-// Result type stays the same as the original CONCAT — nullable VARCHAR.
-RexNode nullLiteral = rexBuilder.makeNullLiteral(original.getType());
-return rexBuilder.makeCall(original.getType(), SqlStdOperatorTable.CASE, List.of(anyNull, nullLiteral, original));
+// Result type must be nullable since the THEN branch returns NULL.
+RelDataType nullableType = rexBuilder.getTypeFactory().createTypeWithNullability(original.getType(), true);
+RexNode nullLiteral = rexBuilder.makeNullLiteral(nullableType);
+return rexBuilder.makeCall(nullableType, SqlStdOperatorTable.CASE, List.of(anyNull, nullLiteral, original));
Suggestion importance[1-10]: 5

__

Why: The concern about type nullability is valid — if original.getType() is non-nullable, passing it as the CASE return type while having a nullable nullLiteral in the THEN branch could cause type validation issues in Calcite. The fix explicitly creates a nullable type, which is more correct.

Low
Fix non-thread-safe static provisioning flag

The dataProvisioned static flag is not thread-safe and can cause race conditions
when tests run in parallel, potentially provisioning the dataset multiple times or
skipping provisioning. Use a volatile keyword or an AtomicBoolean to ensure safe
publication across threads.

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

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

__

Why: The dataProvisioned flag could have thread-safety issues in parallel test execution, but integration tests in OpenSearch typically run sequentially within a class. The suggestion is valid but the impact is low in practice for test code.

Low
General
Verify JSON escaping handles all special characters

The PPL query is embedded directly into a JSON string using string concatenation
with escapeJson. If escapeJson does not handle all edge cases (e.g., embedded
quotes, backslashes, newlines), this could produce malformed JSON and cause test
failures. Verify that escapeJson is comprehensive or use a proper JSON builder.

sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/EvalCommandIT.java [196-202]

 private Map<String, Object> executePpl(String ppl) throws IOException {
     ensureDataProvisioned();
     Request request = new Request("POST", "/_analytics/ppl");
+    // Ensure escapeJson handles all JSON special characters including \, ", \n, \r, \t
     request.setJsonEntity("{\"query\": \"" + escapeJson(ppl) + "\"}");
     Response response = client().performRequest(request);
     return assertOkAndParse(response, "PPL: " + ppl);
 }
Suggestion importance[1-10]: 2

__

Why: The suggestion asks to verify that escapeJson handles all edge cases but doesn't actually change the code — the improved_code is essentially the same as existing_code with just a comment added. This is a verification request rather than a concrete fix.

Low
Suggestions up to commit 5a011c9
CategorySuggestion                                                                                                                                    Impact
General
Prioritize reference-operator lookup over SqlKind resolution

The fromSqlKind resolution runs first, but SqlKind.OTHER_FUNCTION is shared by many
operators (UPPER, LOWER, ABS, etc.) and fromSqlKind is documented to return null for
it. However, if fromSqlKind ever returns a non-null result for SqlKind.OTHER (which
SqlStdOperatorTable.CONCAT uses), it would incorrectly short-circuit before reaching
the referenceOperator identity check. The current fromSqlKind implementation appears
to guard against this, but the resolution order means a future SqlKind.OTHER mapping
in the enum would silently shadow the referenceOperator lookup for CONCAT. Consider
documenting this ordering constraint explicitly, or moving the referenceOperator
check before the SqlKind check.

sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/ScalarFunction.java [193-207]

 public static ScalarFunction fromSqlOperatorWithFallback(SqlOperator operator) {
+    // Check referenceOperator identity first — operators like CONCAT use SqlKind.OTHER
+    // which must NOT be resolved by SqlKind (it is shared). Identity check is O(1) and
+    // takes priority to avoid a future SqlKind.OTHER mapping shadowing it.
+    ScalarFunction byReference = BY_REFERENCE_OPERATOR.get(operator);
+    if (byReference != null) {
+        return byReference;
+    }
     ScalarFunction byKind = fromSqlKind(operator.getKind());
     if (byKind != null) {
         return byKind;
-    }
-    ScalarFunction byReference = BY_REFERENCE_OPERATOR.get(operator);
-    if (byReference != null) {
-        return byReference;
     }
     try {
         return ScalarFunction.valueOf(operator.getName().toUpperCase(Locale.ROOT));
     } catch (IllegalArgumentException ignored) {
         return null;
     }
 }
Suggestion importance[1-10]: 4

__

Why: The suggestion raises a valid concern about future SqlKind.OTHER mappings shadowing the referenceOperator lookup for CONCAT. However, fromSqlKind is documented to return null for SqlKind.OTHER, and the current implementation guards against this. The reordering would be a defensive improvement but addresses a hypothetical future issue rather than a current bug.

Low
Use proper JSON serialization for request body

The PPL query string is embedded directly into a JSON string using string
concatenation with escapeJson. If escapeJson does not properly escape all special
JSON characters (backslash, control characters, etc.), this could produce malformed
JSON. Using a proper JSON serializer to build the request body would be safer and
more robust.

sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/EvalCommandIT.java [196-202]

 private Map<String, Object> executePpl(String ppl) throws IOException {
     ensureDataProvisioned();
     Request request = new Request("POST", "/_analytics/ppl");
-    request.setJsonEntity("{\"query\": \"" + escapeJson(ppl) + "\"}");
+    // Use a proper JSON object to avoid manual escaping issues.
+    org.opensearch.common.xcontent.XContentBuilder builder =
+        org.opensearch.common.xcontent.XContentFactory.jsonBuilder();
+    builder.startObject().field("query", ppl).endObject();
+    request.setJsonEntity(builder.toString());
     Response response = client().performRequest(request);
     return assertOkAndParse(response, "PPL: " + ppl);
 }
Suggestion importance[1-10]: 3

__

Why: The suggestion is valid — using a proper JSON serializer is safer than manual string concatenation with escapeJson. However, escapeJson is presumably already implemented to handle special characters, and this is a test file where the risk is lower. The improved code introduces a dependency on XContentFactory which may not be the preferred approach in this test context.

Low
Guard against double-wrapping in CONCAT adapter

The CASE expression built here has the form CASE WHEN anyNull THEN NULL ELSE
original END, but Calcite's CASE operator expects the arguments in the form
[condition1, value1, ..., elseValue] — which is exactly what's provided. However,
the original RexCall in the ELSE branch still contains the same operands that may be
NULL, meaning DataFusion's concat() (which treats NULL as empty string) will still
be called when all inputs are non-null. This is correct, but the original call is
reused directly — if the adapter is called on an already-adapted node (e.g. in a
recursive rewrite pass), the CASE wrapper will be double-applied. Consider checking
whether original is already wrapped before rewriting.

sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/ConcatFunctionAdapter.java [66-67]

 RexNode nullLiteral = rexBuilder.makeNullLiteral(original.getType());
+// Guard against double-wrapping if this adapter is applied more than once.
+if (original.getOperator() == SqlStdOperatorTable.CASE) {
+    return original;
+}
 return rexBuilder.makeCall(original.getType(), SqlStdOperatorTable.CASE, List.of(anyNull, nullLiteral, original));
Suggestion importance[1-10]: 2

__

Why: The suggestion addresses a theoretical double-wrapping scenario, but the adapt method receives a RexCall with original being the CONCAT operator call — not a CASE call. The check original.getOperator() == SqlStdOperatorTable.CASE would never be true in normal usage since the adapter is only invoked for CONCAT calls. This is an unlikely edge case with minimal practical impact.

Low
Suggestions up to commit 701d627
CategorySuggestion                                                                                                                                    Impact
Possible issue
Ensure JSON escaping method is defined

The escapeJson method is called but never defined in this class, and its absence
will cause a compile error. If it is not inherited from AnalyticsRestTestCase, this
will break the build. Ensure the method is defined or inherited, or inline a minimal
implementation such as ppl.replace("\", "\\").replace(""", "\"").

sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/EvalCommandIT.java [196-202]

 private Map<String, Object> executePpl(String ppl) throws IOException {
     ensureDataProvisioned();
     Request request = new Request("POST", "/_analytics/ppl");
-    request.setJsonEntity("{\"query\": \"" + escapeJson(ppl) + "\"}");
+    String escaped = ppl.replace("\\", "\\\\").replace("\"", "\\\"");
+    request.setJsonEntity("{\"query\": \"" + escaped + "\"}");
     Response response = client().performRequest(request);
     return assertOkAndParse(response, "PPL: " + ppl);
 }
Suggestion importance[1-10]: 4

__

Why: The escapeJson method is called but not defined in this class. If it's not inherited from AnalyticsRestTestCase, this would cause a compile error. However, since the suggestion asks to verify inheritance rather than confirming a definite bug, the score is moderate.

Low
Fix unary operator call argument wrapping

IS_NULL expects a single-element list as its operands, but makeCall here passes the
operand directly as a vararg. The correct Calcite API for unary operators is
rexBuilder.makeCall(operator, List.of(operand)) or the vararg form
rexBuilder.makeCall(operator, operand). Passing operands.get(i) directly to a vararg
makeCall that expects List may silently wrap it incorrectly or throw at runtime
depending on the overload resolved.

sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/ConcatFunctionAdapter.java [57-64]

-RexNode anyNull = rexBuilder.makeCall(SqlStdOperatorTable.IS_NULL, operands.get(0));
+RexNode anyNull = rexBuilder.makeCall(SqlStdOperatorTable.IS_NULL, List.of(operands.get(0)));
 for (int i = 1; i < operands.size(); i++) {
     anyNull = rexBuilder.makeCall(
         SqlStdOperatorTable.OR,
-        anyNull,
-        rexBuilder.makeCall(SqlStdOperatorTable.IS_NULL, operands.get(i))
+        List.of(anyNull, rexBuilder.makeCall(SqlStdOperatorTable.IS_NULL, List.of(operands.get(i))))
     );
 }
Suggestion importance[1-10]: 2

__

Why: The RexBuilder.makeCall method accepts varargs RexNode... so passing operands.get(i) directly is valid Java and will work correctly. The suggestion is based on a false premise that the vararg form is incorrect, making this suggestion inaccurate.

Low
General
Use thread-safe flag for dataset provisioning

Using a static boolean flag for lazy provisioning is not thread-safe and can lead to
double-provisioning if tests run in parallel. Consider using a static AtomicBoolean
or a @BeforeClass-equivalent mechanism to ensure the dataset is provisioned exactly
once.

sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/EvalCommandIT.java [46]

-private static boolean dataProvisioned = false;
+private static final java.util.concurrent.atomic.AtomicBoolean dataProvisioned = new java.util.concurrent.atomic.AtomicBoolean(false);
Suggestion importance[1-10]: 3

__

Why: Using a plain static boolean for lazy provisioning is not thread-safe, but integration tests typically run sequentially in a single thread, making this a low-priority concern. The AtomicBoolean suggestion is valid but has minimal practical impact in this context.

Low

@github-actions

github-actions Bot commented May 5, 2026

Copy link
Copy Markdown
Contributor

❌ Gradle check result for ac52d59:

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?

@github-actions

github-actions Bot commented May 5, 2026

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 4b17c0a

ahkcs added a commit to ahkcs/OpenSearch that referenced this pull request May 5, 2026
…ch-project#21498

Three feedback items in one commit:

1. Co-locate symbolic operator name with the enum constant.
   The static SYMBOLIC_OPERATOR_NAMES map duplicated a property that
   belongs on the enum itself. Moved to a nullable `symbolicOperatorName`
   field on each ScalarFunction constant — currently set only on CONCAT
   ("||"). The reverse-index map is now built from the enum at class-init
   time, so adding a new symbolic operator is a single-site edit on the
   constant rather than a separate map entry.

2. Inline the OR/IS_NULL fold in ConcatFunctionAdapter.
   Drop the temporary List<RexNode> nullChecks and accumulate the
   OR-of-IS_NULLs directly in the loop body. Same generated tree, fewer
   allocations, less to read.

3. Note the Map.of single-line constraint on scalarFunctionAdapters.
   Per-pair formatting is rejected by spotless; left a comment pointing
   future contributors at alphabetical ordering instead, and reordered
   the entries (CONCAT before TIMESTAMP) to make the convention concrete.

No behavioral change. CalciteEvalCommandIT 4/4 still passes against the
analytics-engine route; sandbox per-module check (excluding the
unrelated commons-text dependencyLicenses task) remains green.

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

github-actions Bot commented May 5, 2026

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 701d627

@github-actions

github-actions Bot commented May 5, 2026

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 5a011c9

@ahkcs
ahkcs requested a review from expani May 5, 2026 21:51
@github-actions

github-actions Bot commented May 5, 2026

Copy link
Copy Markdown
Contributor

✅ Gradle check result for 5a011c9: SUCCESS

@codecov

codecov Bot commented May 5, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 73.41%. Comparing base (680cee8) to head (e56f60d).

Additional details and impacted files
@@             Coverage Diff              @@
##               main   #21498      +/-   ##
============================================
+ Coverage     73.35%   73.41%   +0.05%     
- Complexity    74365    74400      +35     
============================================
  Files          5970     5970              
  Lines        338261   338261              
  Branches      48752    48752              
============================================
+ Hits         248133   248325     +192     
+ Misses        70310    70147     -163     
+ Partials      19818    19789      -29     

☔ 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.

@expani expani left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @ahkcs for the iteration.

Left some minor comments, but approving it nonetheless.

ahkcs added a commit to ahkcs/OpenSearch that referenced this pull request May 5, 2026
…earch-project#21498

Two feedback items in one commit:

1. Drop the redundant Map.copyOf on BY_REFERENCE_OPERATOR. The HashMap
   built in the static initializer is private static final and is only
   read via the resolver's get() — never returned, never iterated. The
   immutability wrapper added an allocation without conferring any
   external safety guarantee. Comment explains the reasoning so future
   readers don't reintroduce the wrap.

2. Add ConcatFunctionAdapterTests with seven structural assertions on
   the CASE rewrite contract:

   - testAdaptBinaryConcatProducesCaseWrapper: rewritten root is a
     three-operand CASE (condition, then, else).
   - testAdaptedCaseElseBranchIsOriginalConcat: else branch is the
     original RexCall by reference (assertSame, not assertEquals) —
     downstream substrait conversion expects the same object the
     resolver annotated.
   - testAdaptedCaseThenBranchIsNullLiteralOfMatchingSqlType: then
     branch is a NULL literal whose SQL type name matches the original
     CONCAT's. Comment explains why we compare type name rather than
     full RelDataType (RexBuilder.makeNullLiteral promotes nullability,
     so the full types differ harmlessly).
   - testAdaptedCaseConditionIsOrOfIsNullChecks: condition is OR with
     each disjunct an IS_NULL wrapping the corresponding original
     operand at matching index — null-propagation contract is per
     operand.
   - testAdaptPreservesReturnType: full RelDataType identity between
     adapted CASE and original CONCAT — locks the type-preserving
     argument of rexBuilder.makeCall(originalType, CASE, ...).
   - testAdaptNaryConcatChainsIsNullChecksLeftAssociative: builds a
     ternary CONCAT via SqlLibraryOperators.CONCAT_FUNCTION and
     verifies the left-fold structure OR(OR(IS_NULL(a), IS_NULL(b)),
     IS_NULL(c)) — the binary `||` only ever appears with arity 2 in
     production, but the loop's correctness for arbitrary N is now a
     test invariant.
   - testAdaptSingleOperandConcatPassesThroughUnchanged: 1-operand
     call returns input by reference; documents the early-out branch.

   Each test pins one structural property in isolation, so a regression
   that drops any one piece of the contract surfaces with a focused
   failure rather than at IT-level row-mismatch noise.

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

github-actions Bot commented May 5, 2026

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit d6641af

@github-actions

github-actions Bot commented May 6, 2026

Copy link
Copy Markdown
Contributor

❕ Gradle check result for d6641af: UNSTABLE

Please review all flaky tests that succeeded after retry and create an issue if one does not already exist to track the flaky failure.

@github-actions

github-actions Bot commented May 6, 2026

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit e56f60d

@github-actions

github-actions Bot commented May 6, 2026

Copy link
Copy Markdown
Contributor

❕ Gradle check result for e56f60d: UNSTABLE

Please review all flaky tests that succeeded after retry and create an issue if one does not already exist to track the flaky failure.

@mch2

mch2 commented May 6, 2026

Copy link
Copy Markdown
Member

@ahkcs can u pls rebase to pick up sandbox check fix.

ahkcs added a commit to ahkcs/OpenSearch that referenced this pull request May 6, 2026
…ch-project#21498

Three feedback items in one commit:

1. Co-locate symbolic operator name with the enum constant.
   The static SYMBOLIC_OPERATOR_NAMES map duplicated a property that
   belongs on the enum itself. Moved to a nullable `symbolicOperatorName`
   field on each ScalarFunction constant — currently set only on CONCAT
   ("||"). The reverse-index map is now built from the enum at class-init
   time, so adding a new symbolic operator is a single-site edit on the
   constant rather than a separate map entry.

2. Inline the OR/IS_NULL fold in ConcatFunctionAdapter.
   Drop the temporary List<RexNode> nullChecks and accumulate the
   OR-of-IS_NULLs directly in the loop body. Same generated tree, fewer
   allocations, less to read.

3. Note the Map.of single-line constraint on scalarFunctionAdapters.
   Per-pair formatting is rejected by spotless; left a comment pointing
   future contributors at alphabetical ordering instead, and reordered
   the entries (CONCAT before TIMESTAMP) to make the convention concrete.

No behavioral change. CalciteEvalCommandIT 4/4 still passes against the
analytics-engine route; sandbox per-module check (excluding the
unrelated commons-text dependencyLicenses task) remains green.

Signed-off-by: Kai Huang <ahkcs@amazon.com>
ahkcs added a commit to ahkcs/OpenSearch that referenced this pull request May 6, 2026
…earch-project#21498

Two feedback items in one commit:

1. Drop the redundant Map.copyOf on BY_REFERENCE_OPERATOR. The HashMap
   built in the static initializer is private static final and is only
   read via the resolver's get() — never returned, never iterated. The
   immutability wrapper added an allocation without conferring any
   external safety guarantee. Comment explains the reasoning so future
   readers don't reintroduce the wrap.

2. Add ConcatFunctionAdapterTests with seven structural assertions on
   the CASE rewrite contract:

   - testAdaptBinaryConcatProducesCaseWrapper: rewritten root is a
     three-operand CASE (condition, then, else).
   - testAdaptedCaseElseBranchIsOriginalConcat: else branch is the
     original RexCall by reference (assertSame, not assertEquals) —
     downstream substrait conversion expects the same object the
     resolver annotated.
   - testAdaptedCaseThenBranchIsNullLiteralOfMatchingSqlType: then
     branch is a NULL literal whose SQL type name matches the original
     CONCAT's. Comment explains why we compare type name rather than
     full RelDataType (RexBuilder.makeNullLiteral promotes nullability,
     so the full types differ harmlessly).
   - testAdaptedCaseConditionIsOrOfIsNullChecks: condition is OR with
     each disjunct an IS_NULL wrapping the corresponding original
     operand at matching index — null-propagation contract is per
     operand.
   - testAdaptPreservesReturnType: full RelDataType identity between
     adapted CASE and original CONCAT — locks the type-preserving
     argument of rexBuilder.makeCall(originalType, CASE, ...).
   - testAdaptNaryConcatChainsIsNullChecksLeftAssociative: builds a
     ternary CONCAT via SqlLibraryOperators.CONCAT_FUNCTION and
     verifies the left-fold structure OR(OR(IS_NULL(a), IS_NULL(b)),
     IS_NULL(c)) — the binary `||` only ever appears with arity 2 in
     production, but the loop's correctness for arbitrary N is now a
     test invariant.
   - testAdaptSingleOperandConcatPassesThroughUnchanged: 1-operand
     call returns input by reference; documents the early-out branch.

   Each test pins one structural property in isolation, so a regression
   that drops any one piece of the contract surfaces with a focused
   failure rather than at IT-level row-mismatch noise.

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

github-actions Bot commented May 6, 2026

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit e88711f

@ahkcs

ahkcs commented May 6, 2026

Copy link
Copy Markdown
Contributor Author

@ahkcs can u pls rebase to pick up sandbox check fix.

Rebased

@github-actions

github-actions Bot commented May 6, 2026

Copy link
Copy Markdown
Contributor

❌ Gradle check result for e88711f: 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?

@github-actions

github-actions Bot commented May 6, 2026

Copy link
Copy Markdown
Contributor

❌ Gradle check result for e88711f: 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?

ahkcs added 2 commits May 6, 2026 13:54
Calcite emits SqlBinaryOperators (e.g. `||`, the lowering target of PPL
string `+`) with SqlKind.OTHER and a non-identifier name. The existing
ScalarFunction.fromSqlKind / fromSqlFunction pair fails to resolve these:
fromSqlKind misses (OTHER is shared), fromSqlFunction throws because
`||` is not a SqlFunction (it's a SqlBinaryOperator). The planner-side
fallout is "No backend supports scalar function [null] among
[datafusion]" with no useful name in the error.

Introduce ScalarFunction.fromSqlOperator(SqlOperator) — the unified entry
point used by OpenSearchProjectRule, OpenSearchFilterRule, and
BackendPlanAdapter in subsequent commits. Resolution order:

  1. SqlKind via fromSqlKind (covers PLUS, CAST, COALESCE, etc.)
  2. Symbolic-name lookup (handles `||` -> CONCAT)
  3. Identifier-name valueOf fallback (covers UPPER, LOWER, etc.)

The symbolic-name table currently has one entry (`||` -> CONCAT) but is
the documented extension point for future SqlBinaryOperators with non-
identifier names.

Also adds SAFE_CAST as a sibling enum constant to CAST. PPL emits
explicit `CAST(... AS ...)` lowered to Calcite's SqlKind.SAFE_CAST when
the source value may be NULL or the conversion may fail. SAFE_CAST and
CAST share the same backend semantics (DataFusion's native cast already
returns NULL on conversion failure) but resolve through distinct
SqlKinds, so they need distinct enum entries.

Unit test pins all three resolution branches plus the unknown-operator
return-null contract — a regression that drops a branch surfaces here
rather than as an opaque "[null]" IT failure.

Signed-off-by: Kai Huang <ahkcs@amazon.com>
Three call sites resolved a RexCall's operator using the same two-step
pattern (SqlKind first, SqlFunction-cast second) and all three failed
identically on `||` (a SqlBinaryOperator with SqlKind.OTHER):

  - OpenSearchProjectRule.resolveScalarViableBackends
  - OpenSearchFilterRule (predicate operator resolution)
  - BackendPlanAdapter.resolveFunction (per-function adapter dispatch)

Migrate all three to ScalarFunction.fromSqlOperator, the unified
resolver added in the previous commit. Behavior for previously-resolved
operators is unchanged — fromSqlOperator delegates to fromSqlKind first,
so anything that resolved through SqlKind continues to. New behavior:
`||` now resolves to CONCAT, and unrecognized operators return null
(catching the IllegalArgumentException that fromSqlFunction's valueOf
threw before; the call sites already handled null and now produce a
better-formed error message that includes the operator name).

Also drop the unused SqlFunction import in OpenSearchFilterRule and
BackendPlanAdapter, and tighten the OpenSearchProjectRule error message
to fall back to operator.getName() when the resolver returns null —
"[null]" was unactionable for triage; "[||]" or "[<unknown_name>]"
points directly at the missing capability.

Signed-off-by: Kai Huang <ahkcs@amazon.com>
ahkcs added 6 commits May 6, 2026 13:55
…null adapter

Three new ScalarFunctions in STANDARD_PROJECT_OPS:

  - CONCAT  — lowering target of PPL `eval`'s `+` for strings (Calcite
              emits `||`, resolved to CONCAT through the symbolic-name
              branch of ScalarFunction.fromSqlOperator)
  - CAST    — covers PPL's explicit `CAST(... AS ...)` over non-null
              source types (Calcite emits SqlKind.CAST)
  - SAFE_CAST — same surface, but emitted by Calcite when the source
              value is nullable (SqlKind.SAFE_CAST)

CONCAT additionally needs a ScalarFunctionAdapter to preserve null
semantics. Calcite's `||` follows the SQL standard: if any operand is
NULL, the result is NULL. Substrait's default `concat` extension is
documented with the same semantics, but DataFusion's substrait reader
maps it to the DataFusion `concat()` function — which deviates from the
standard and treats NULL operands as empty strings. PPL queries like
`'Age: ' + CAST(null AS STRING)` expect NULL, not 'Age: '.

ConcatFunctionAdapter rewrites `||(a, b, ...)` into

  CASE WHEN a IS NULL OR b IS NULL OR ... THEN NULL ELSE ||(a, b, ...) END

The inner `||` survives unchanged and serializes through the same
Substrait conversion path; the surrounding CASE/IS_NULL short-circuits
the DataFusion `concat()` call whenever any operand is NULL, restoring
SQL-standard null propagation without a custom DataFusion UDF.

Trade-off: the rewrite double-evaluates each operand (once in IS_NULL,
once in the inner `||`). For RexInputRef and RexLiteral operands —
the only shapes PPL emits today for string concat — this is free; for
nested calls the cost is proportional to operand count, not operand
depth, since each `||` adapter wraps one CASE around its direct call.
A custom null-propagating concat UDF (Bucket-3 work in
sandbox/plugins/analytics-backend-datafusion/rust) is the alternative
but disproportionate for a Bucket-1 surface.

Signed-off-by: Kai Huang <ahkcs@amazon.com>
Self-contained integration test for PPL `eval` on the analytics-engine
route. Mirrors CalciteEvalCommandIT in opensearch-project/sql so the
analytics-engine path can be verified inside core without cross-plugin
dependencies on the SQL plugin. Each test sends a PPL query through
POST /_analytics/ppl (exposed by test-ppl-frontend) which runs the
same UnifiedQueryPlanner -> CalciteRelNodeVisitor -> Substrait ->
DataFusion pipeline as the SQL plugin's force-routed analytics path.

Four tests on the calcs dataset cover the eval surface this PR enables:

  - testEvalStringConcatLiteralPlusField — `'literal' + str_field`
    exercises the symbolic-name resolution for `||` and the CONCAT
    capability; null str field rows assert null propagation through
    the CASE adapter.
  - testEvalStringConcatWithCastIntField — `'literal' + CAST(int AS STRING)`
    exercises both CAST/SAFE_CAST and CONCAT in the same projection;
    null int rows confirm CAST(NULL) -> NULL propagates through the
    surrounding concat.
  - testEvalStringConcatMultipleLiteralsAndFields — chained four-arg
    concat exercises the recursive AnnotatedProjectExpression strip
    for nested project calls.
  - testEvalStringConcatTwoFields — pure field-to-field concat with
    no literal operands; planner takes the hasFieldRef=true path in
    resolveScalarViableBackends.

Reuses the existing calcs dataset (no new fixtures). Once this lands,
the SQL-plugin's CalciteEvalCommandIT is verification-only — this QA
IT is the source of truth for the analytics-engine path.

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

Per @expani's PR feedback: the method walks three resolution paths
(SqlKind, symbolic-name table, identifier-name valueOf) before returning
null, so the name should advertise the fallback behavior at the call
site rather than only in the javadoc.

Mechanical rename across all callers — `ScalarFunction.fromSqlOperator`
-> `ScalarFunction.fromSqlOperatorWithFallback` in:
  - the resolver itself plus its 7 unit tests
  - OpenSearchProjectRule (2 call sites)
  - OpenSearchFilterRule (1 call site)
  - BackendPlanAdapter.resolveFunction (1 call site)
  - EvalCommandIT javadoc cross-reference

No behavioral change.

Signed-off-by: Kai Huang <ahkcs@amazon.com>
…ch-project#21498

Three feedback items in one commit:

1. Co-locate symbolic operator name with the enum constant.
   The static SYMBOLIC_OPERATOR_NAMES map duplicated a property that
   belongs on the enum itself. Moved to a nullable `symbolicOperatorName`
   field on each ScalarFunction constant — currently set only on CONCAT
   ("||"). The reverse-index map is now built from the enum at class-init
   time, so adding a new symbolic operator is a single-site edit on the
   constant rather than a separate map entry.

2. Inline the OR/IS_NULL fold in ConcatFunctionAdapter.
   Drop the temporary List<RexNode> nullChecks and accumulate the
   OR-of-IS_NULLs directly in the loop body. Same generated tree, fewer
   allocations, less to read.

3. Note the Map.of single-line constraint on scalarFunctionAdapters.
   Per-pair formatting is rejected by spotless; left a comment pointing
   future contributors at alphabetical ordering instead, and reordered
   the entries (CONCAT before TIMESTAMP) to make the convention concrete.

No behavioral change. CalciteEvalCommandIT 4/4 still passes against the
analytics-engine route; sandbox per-module check (excluding the
unrelated commons-text dependencyLicenses task) remains green.

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

Per @expani's PR follow-up: the symbolic-name string ("||") was a
runtime-coupled identifier that could silently drift if Calcite renamed
the operator. Replace it with a direct reference to the Calcite operator
constant (SqlStdOperatorTable.CONCAT), so the link is enforced at
compile time and a Calcite-side rename surfaces as a build failure here.

  - String symbolicOperatorName -> SqlOperator referenceOperator on the
    enum constructor.
  - CONCAT now points at SqlStdOperatorTable.CONCAT instead of "||".
  - Reverse index switches from Map<String, ScalarFunction> keyed by
    operator name to Map<SqlOperator, ScalarFunction> keyed by operator
    identity. Calcite's standard operators are singletons, so identity
    lookup is exact.
  - Unit test renamed (testFromSqlOperatorResolvesPipeConcatViaReferenceOperator)
    and its comment updated; the assertions on `getName()` / `getKind()`
    are kept as documentation of WHY this branch is needed at all.

No behavioral change in the resolution logic — same three-step chain
(SqlKind, then this branch, then identifier-name valueOf), with the
middle branch now identity-comparing rather than name-comparing.

CalciteEvalCommandIT 4/4 still passes; ScalarFunctionTests 7/7.

Signed-off-by: Kai Huang <ahkcs@amazon.com>
…earch-project#21498

Two feedback items in one commit:

1. Drop the redundant Map.copyOf on BY_REFERENCE_OPERATOR. The HashMap
   built in the static initializer is private static final and is only
   read via the resolver's get() — never returned, never iterated. The
   immutability wrapper added an allocation without conferring any
   external safety guarantee. Comment explains the reasoning so future
   readers don't reintroduce the wrap.

2. Add ConcatFunctionAdapterTests with seven structural assertions on
   the CASE rewrite contract:

   - testAdaptBinaryConcatProducesCaseWrapper: rewritten root is a
     three-operand CASE (condition, then, else).
   - testAdaptedCaseElseBranchIsOriginalConcat: else branch is the
     original RexCall by reference (assertSame, not assertEquals) —
     downstream substrait conversion expects the same object the
     resolver annotated.
   - testAdaptedCaseThenBranchIsNullLiteralOfMatchingSqlType: then
     branch is a NULL literal whose SQL type name matches the original
     CONCAT's. Comment explains why we compare type name rather than
     full RelDataType (RexBuilder.makeNullLiteral promotes nullability,
     so the full types differ harmlessly).
   - testAdaptedCaseConditionIsOrOfIsNullChecks: condition is OR with
     each disjunct an IS_NULL wrapping the corresponding original
     operand at matching index — null-propagation contract is per
     operand.
   - testAdaptPreservesReturnType: full RelDataType identity between
     adapted CASE and original CONCAT — locks the type-preserving
     argument of rexBuilder.makeCall(originalType, CASE, ...).
   - testAdaptNaryConcatChainsIsNullChecksLeftAssociative: builds a
     ternary CONCAT via SqlLibraryOperators.CONCAT_FUNCTION and
     verifies the left-fold structure OR(OR(IS_NULL(a), IS_NULL(b)),
     IS_NULL(c)) — the binary `||` only ever appears with arity 2 in
     production, but the loop's correctness for arbitrary N is now a
     test invariant.
   - testAdaptSingleOperandConcatPassesThroughUnchanged: 1-operand
     call returns input by reference; documents the early-out branch.

   Each test pins one structural property in isolation, so a regression
   that drops any one piece of the contract surfaces with a focused
   failure rather than at IT-level row-mismatch noise.

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

github-actions Bot commented May 6, 2026

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit eb2121e

ahkcs added a commit to ahkcs/OpenSearch that referenced this pull request May 6, 2026
PR opensearch-project#21498 ("Enable PPL eval string concat on the analytics-engine route via
DataFusion CONCAT/CAST") lands the eval-side capability surface this PR
also touched, with a more general fix:

* Adds {@code SAFE_CAST} as a {@code SqlKind.SAFE_CAST}-keyed enum constant
  (not {@code SqlKind.OTHER_FUNCTION} like this PR did — theirs is
  semantically correct).
* Wires {@code CAST}, {@code CONCAT}, {@code SAFE_CAST} into
  {@code STANDARD_PROJECT_OPS}.
* Adds a {@code ConcatFunctionAdapter} that wraps {@code ||} with a
  CASE/IS_NULL null-propagation guard (the standard-SQL semantic this PR
  flagged as a follow-up).
* Migrates {@code OpenSearchProjectRule}, {@code OpenSearchFilterRule},
  and {@code BackendPlanAdapter} to a new
  {@code ScalarFunction.fromSqlOperatorWithFallback(SqlOperator)} resolver
  that handles every symbolic operator (not just {@code ||}) and gracefully
  swallows the {@code IllegalArgumentException} from unknown names.

This commit removes the now-redundant pieces from this PR:

* Drop the {@code SAFE_CAST} enum constant.
* Drop {@code CAST}, {@code SAFE_CAST}, {@code CONCAT} from
  {@code STANDARD_PROJECT_OPS}.
* Revert the {@code OpenSearchProjectRule} CONCAT special-case + IAE
  swallow — opensearch-project#21498's {@code fromSqlOperatorWithFallback} migration is the
  right fix.

What stays in this PR (not covered by opensearch-project#21498):

* {@code AND} / {@code OR} / {@code NOT} enum constants and their
  {@code STANDARD_PROJECT_OPS} entries — boolean operators inside CASE
  predicates of {@code count(eval(a > 1 and b < 2))}-style stats.
* {@code IS_NULL} / {@code IS_NOT_NULL} / {@code CASE} / {@code NULLIF}
  in {@code STANDARD_PROJECT_OPS} — eval/sort-pushdown sub-expressions.
* {@code UPPER} / {@code LOWER} / {@code TRIM} / {@code SUBSTRING} /
  {@code CHAR_LENGTH} / {@code FLOOR} / {@code ABS} in
  {@code STANDARD_PROJECT_OPS} — sort-pushdown and eval string ops.
* {@code STDDEV_POP} / {@code STDDEV_SAMP} / {@code VAR_POP} /
  {@code VAR_SAMP} in {@code AGG_FUNCTIONS}, plus the
  {@code aggregateCapabilities()} switch dispatch on
  {@code AggregateFunction.Type} so a single mixed-category list doesn't
  trip the {@code AggregateCapability.simple()} assertion.

Also bundles {@code commons-text:1.11.0} in {@code analytics-engine}'s
zip — Calcite's {@code SqlFunctions.<clinit>} eagerly references
{@code org.apache.commons.text.similarity.LevenshteinDistance}
(SOUNDEX/JARO_WINKLER), and any agg query that touches
{@code SqlFunctions} crashes the cluster with
{@code NoClassDefFoundError} otherwise. The
{@code resolutionStrategy.force} pin in this build.gradle pins the
version but doesn't bundle the jar.

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

Bucket-1 capability-registry expansion for the analytics-engine route. Pairs
with opensearch-project#21498 (eval string concat / CAST / SAFE_CAST / `||`-resolver /
ConcatFunctionAdapter) — independent surfaces, no overlap. After this PR,
`CalciteFieldsCommandIT` 39/39, `CalciteRenameCommandIT` 2/2,
`CalciteHeadCommandIT` 4/4 are 100% green under
`tests.analytics.force_routing=true`, and the Sort suites pick up the bulk
of their cast / abs / substring push-down gains.

All changes are Bucket-1 in the routing-doc taxonomy: the DataFusion
runtime already implements every operator listed; this PR just declares
the capability so the Layer-2 planner stops rejecting the calls.

Three additive layers:

1. ScalarFunction — add AND, OR, NOT enum constants. The filter rule
   structurally recurses into AND/OR/NOT and never looks them up, but
   the project rule does — these must appear in the enum +
   STANDARD_PROJECT_OPS for eval predicates like
   `count(eval(balance > 20000 and age < 35))` where AND is a
   sub-expression of CASE.

2. DataFusionAnalyticsBackendPlugin —
   - STANDARD_PROJECT_OPS additions:
     * FLOOR, ABS — sort-by `abs(balance)` push-down
       (SortCommandIT.testPushdownSortExpressionContainsNull).
     * IS_NULL, IS_NOT_NULL — sort-by `isnull(...)` and eval guards.
     * AND, OR, NOT — boolean ops in CASE predicates.
     * CASE, NULLIF — conditional projections in eval.
     * UPPER, LOWER, TRIM, SUBSTRING, CHAR_LENGTH, CONCAT — sort-by
       `substring(...)` push-down (CalcitePPLSortIT.testPushdownSortStringExpression),
       eval string transforms.
   - AGG_FUNCTIONS additions: STDDEV_POP, STDDEV_SAMP, VAR_POP, VAR_SAMP
     for `stats stddev_samp(...)` / `stats var_samp(...)`.
   - aggregateCapabilities() now dispatches on AggregateFunction.Type so
     a single mixed-category list works — the previous unconditional
     AggregateCapability.simple(...) asserts on non-SIMPLE inputs and
     crashes plugin init when STDDEV/VAR are added.

3. analytics-engine/build.gradle — bundle commons-text:1.11.0. Calcite's
   SqlFunctions.<clinit> eagerly references
   org.apache.commons.text.similarity.LevenshteinDistance
   (SOUNDEX/JARO_WINKLER); without bundling the jar, the first agg query
   that touches SqlFunctions kills the cluster with NoClassDefFoundError.
   The existing resolutionStrategy.force pin pins the version but
   doesn't bundle.

Test plan:
* `./gradlew :sandbox:libs:analytics-framework:check
   :sandbox:plugins:analytics-backend-datafusion:check
   :sandbox:plugins:analytics-engine:check -Dsandbox.enabled=true` green.
* SQL-plugin ITs against this branch (cluster) + companion SQL plugin
  opensearch-project/sql#5413, with
  `-Dtests.analytics.force_routing=true -Dtests.analytics.parquet_indices=true`.
  Routing verified: 654 analytics-engine PlannerImpl entries, 0 v2
  PPLService entries.

Out of scope (separate follow-ups, surface mostly orthogonal):
* `Unable to find binding for call AVG($N)` — Substrait isthmus' default
  AggregateFunctionConverter rejects Calcite's AVG/STDDEV_SAMP/VAR_SAMP
  signatures. Needs an AggregateSig-style additional-mappings hook
  registered in DataFusionFragmentConvertor. Once unblocked, the new
  STDDEV/VAR entries here will start contributing real test wins.
* Window functions — `dedup` lowers to ROW_NUMBER OVER. RexOver reaches
  the project rule but isn't recognized by ScalarFunction.fromSqlKind.
  Blocks CalciteDedupCommandIT and CalcitePPLDedupIT.
* Advanced aggregates / PPL functions — first, last, take, arg_max,
  percentile_approx, distinct_count_approx, PPL `span` need new enum
  constants + DataFusion adapters or YAML extensions.

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

mch2 commented May 6, 2026

Copy link
Copy Markdown
Member

going ahead all sandbox code and sandbox check passes:

gc again:

Use queue information to find build number in Jenkins if available
jq: parse error: Invalid numeric literal at line 1, column 7
WORKFLOW_URL
Job not started yet. Waiting for 60 seconds before next attempt.
time passed: 3540

@mch2
mch2 merged commit ed78d03 into opensearch-project:main May 6, 2026
13 of 14 checks passed
ahkcs added a commit to ahkcs/OpenSearch that referenced this pull request May 6, 2026
…elds/rename/head/sort

Bucket-1 capability-registry expansion for the analytics-engine route — narrow
scope: only the two scalar functions PPL sort push-down materialises into a
projection (ABS, SUBSTRING). Fields / rename / head don't add scalar surface;
they're covered here purely by new QA ITs that lock in the routing-and-shape
behavior end-to-end through `POST /_analytics/ppl`.

After this PR (with the eval-side surface from opensearch-project#21498 already on main):
* `CalciteFieldsCommandIT`, `CalciteRenameCommandIT`, `CalciteHeadCommandIT`
  100% green on the analytics path under `tests.analytics.force_routing=true`.
* `CalciteSortCommandIT` and `CalcitePPLSortIT` pick up the cast / abs /
  substring push-down failures (CAST is already in `STANDARD_PROJECT_OPS`
  upstream from opensearch-project#21476; ABS and SUBSTRING are this PR's contribution).

## Changes

**1. `DataFusionAnalyticsBackendPlugin` — `STANDARD_PROJECT_OPS` += ABS, SUBSTRING.**
PPL sort push-down lifts an expression like `abs(num0)` or `substring(str0, 1, 3)`
into a `LogicalProject` child of the sort, which is what the project rule's
capability check sees. DataFusion has both natively; isthmus' default extension
catalog already binds them. Without this, the analytics planner rejects the
projection with
`No backend supports scalar function [ABS] among [datafusion]`.

**2. QA ITs in `sandbox/qa/analytics-engine-rest`** — one per command, each
self-contained and provisioning the existing `calcs` parquet-backed dataset
via `DatasetProvisioner`. Tests fire through `POST /_analytics/ppl` so the
core build can validate the analytics-engine path without the SQL plugin.
Mirror the failing surface in `CalciteFieldsCommandIT` /
`CalciteRenameCommandIT` / `CalciteHeadCommandIT` /
`CalciteSortCommandIT` one query at a time:

* `FieldsCommandIT` (5 tests) — basic projection, single-column, explicit
  order, suffix-wildcard `*0` (set-equality, since wildcard expansion order
  isn't part of the contract), and `fields - num*` exclusion.
* `RenameCommandIT` (4 tests) — single rename, multi-rename, post-rename
  reference fails with "not found", backtick-quoted target names.
* `HeadCommandIT` (5 tests) — default-10 cap, explicit count, count > total
  rows, `head N from M` offset, and value-equality on the first 5 rows
  (parquet preserves insertion order, so this is deterministic).
* `SortCommandIT` (5 tests) — plain ASC/DESC by integer (with calcs' 6
  null int0 entries placed at the head/tail per Calcite's nulls-first/last
  defaults), `eval n = abs(num0) | sort n` covering the 9 null +
  8 non-null abs values, and `eval s = substring(str2, 1, 3) | sort s`
  validating the SUBSTRING capability end-to-end against the 17-row
  calcs dataset.

## Test plan

* `./gradlew :sandbox:qa:analytics-engine-rest:integTest -Dsandbox.enabled=true
  --tests "*FieldsCommandIT" --tests "*RenameCommandIT" --tests "*HeadCommandIT"
  --tests "*SortCommandIT"` — 19/19 green.
* `./gradlew check -p sandbox -Dsandbox.enabled=true` — green (the unrelated
  `ScalarDateTimeFunctionIT.testConvertTz` flake from a stale local
  `libopensearch_native.dylib` resolved by rebuilding the Rust crate; not
  caused by this PR).
* SQL-plugin Calcite ITs against this branch + companion
  opensearch-project/sql#5413, with
  `-Dtests.analytics.force_routing=true -Dtests.analytics.parquet_indices=true`:
  `CalciteFieldsCommandIT` 39/39, `CalciteRenameCommandIT` 2/2,
  `CalciteHeadCommandIT` 4/4, plus +5 sort-push-down wins in
  `CalciteSortCommandIT` and +1 in `CalcitePPLSortIT` from the ABS / SUBSTRING
  capability additions.

## Out of scope (separate follow-ups)

* `Unable to find binding for call AVG($N)` Substrait-isthmus issue — needs an
  `AggregateSig`-style additional-mappings hook in
  `DataFusionFragmentConvertor`.
* Window functions (`dedup` lowers to `ROW_NUMBER OVER`).
* Advanced aggregates (`first`, `last`, `take`, `arg_max`, `percentile_approx`,
  `distinct_count_approx`) and PPL `span`.
* The eval-predicate surface (`AND`/`OR`/`NOT` in CASE projections,
  `IS_NULL`/`IS_NOT_NULL`, broader string/conditional ops) and STDDEV/VAR
  aggregates — kept out of this PR to keep the scope focused on the four
  commands the QA ITs cover.

Signed-off-by: Kai Huang <ahkcs@amazon.com>
mch2 pushed a commit that referenced this pull request May 6, 2026
…elds/rename/head/sort (#21521)

Bucket-1 capability-registry expansion for the analytics-engine route — narrow
scope: only the two scalar functions PPL sort push-down materialises into a
projection (ABS, SUBSTRING). Fields / rename / head don't add scalar surface;
they're covered here purely by new QA ITs that lock in the routing-and-shape
behavior end-to-end through `POST /_analytics/ppl`.

After this PR (with the eval-side surface from #21498 already on main):
* `CalciteFieldsCommandIT`, `CalciteRenameCommandIT`, `CalciteHeadCommandIT`
  100% green on the analytics path under `tests.analytics.force_routing=true`.
* `CalciteSortCommandIT` and `CalcitePPLSortIT` pick up the cast / abs /
  substring push-down failures (CAST is already in `STANDARD_PROJECT_OPS`
  upstream from #21476; ABS and SUBSTRING are this PR's contribution).

## Changes

**1. `DataFusionAnalyticsBackendPlugin` — `STANDARD_PROJECT_OPS` += ABS, SUBSTRING.**
PPL sort push-down lifts an expression like `abs(num0)` or `substring(str0, 1, 3)`
into a `LogicalProject` child of the sort, which is what the project rule's
capability check sees. DataFusion has both natively; isthmus' default extension
catalog already binds them. Without this, the analytics planner rejects the
projection with
`No backend supports scalar function [ABS] among [datafusion]`.

**2. QA ITs in `sandbox/qa/analytics-engine-rest`** — one per command, each
self-contained and provisioning the existing `calcs` parquet-backed dataset
via `DatasetProvisioner`. Tests fire through `POST /_analytics/ppl` so the
core build can validate the analytics-engine path without the SQL plugin.
Mirror the failing surface in `CalciteFieldsCommandIT` /
`CalciteRenameCommandIT` / `CalciteHeadCommandIT` /
`CalciteSortCommandIT` one query at a time:

* `FieldsCommandIT` (5 tests) — basic projection, single-column, explicit
  order, suffix-wildcard `*0` (set-equality, since wildcard expansion order
  isn't part of the contract), and `fields - num*` exclusion.
* `RenameCommandIT` (4 tests) — single rename, multi-rename, post-rename
  reference fails with "not found", backtick-quoted target names.
* `HeadCommandIT` (5 tests) — default-10 cap, explicit count, count > total
  rows, `head N from M` offset, and value-equality on the first 5 rows
  (parquet preserves insertion order, so this is deterministic).
* `SortCommandIT` (5 tests) — plain ASC/DESC by integer (with calcs' 6
  null int0 entries placed at the head/tail per Calcite's nulls-first/last
  defaults), `eval n = abs(num0) | sort n` covering the 9 null +
  8 non-null abs values, and `eval s = substring(str2, 1, 3) | sort s`
  validating the SUBSTRING capability end-to-end against the 17-row
  calcs dataset.

## Test plan

* `./gradlew :sandbox:qa:analytics-engine-rest:integTest -Dsandbox.enabled=true
  --tests "*FieldsCommandIT" --tests "*RenameCommandIT" --tests "*HeadCommandIT"
  --tests "*SortCommandIT"` — 19/19 green.
* `./gradlew check -p sandbox -Dsandbox.enabled=true` — green (the unrelated
  `ScalarDateTimeFunctionIT.testConvertTz` flake from a stale local
  `libopensearch_native.dylib` resolved by rebuilding the Rust crate; not
  caused by this PR).
* SQL-plugin Calcite ITs against this branch + companion
  opensearch-project/sql#5413, with
  `-Dtests.analytics.force_routing=true -Dtests.analytics.parquet_indices=true`:
  `CalciteFieldsCommandIT` 39/39, `CalciteRenameCommandIT` 2/2,
  `CalciteHeadCommandIT` 4/4, plus +5 sort-push-down wins in
  `CalciteSortCommandIT` and +1 in `CalcitePPLSortIT` from the ABS / SUBSTRING
  capability additions.

## Out of scope (separate follow-ups)

* `Unable to find binding for call AVG($N)` Substrait-isthmus issue — needs an
  `AggregateSig`-style additional-mappings hook in
  `DataFusionFragmentConvertor`.
* Window functions (`dedup` lowers to `ROW_NUMBER OVER`).
* Advanced aggregates (`first`, `last`, `take`, `arg_max`, `percentile_approx`,
  `distinct_count_approx`) and PPL `span`.
* The eval-predicate surface (`AND`/`OR`/`NOT` in CASE projections,
  `IS_NULL`/`IS_NOT_NULL`, broader string/conditional ops) and STDDEV/VAR
  aggregates — kept out of this PR to keep the scope focused on the four
  commands the QA ITs cover.

Signed-off-by: Kai Huang <ahkcs@amazon.com>
mch2 pushed a commit that referenced this pull request May 7, 2026
)

PPL `fieldformat` is a Calcite-only command that lowers to a plain Eval node
(see SQL plugin's `AstBuilder.visitFieldformatCommand`). Its expressions go
through Calcite's || (CONCAT) operator and CAST, both already wired in the
DataFusion backend's STANDARD_PROJECT_OPS via #21498. **No code changes
required for the analytics route — this PR is QA-only.**

The unique surface vs plain `eval` is the prefix-{`.`} and suffix-{`.`}
string-concat sugar emitted by `AstExpressionBuilder.visitFieldFormatEvalClause`
for the StringDotlogicalExpression / LogicalExpressionDotString rules:

  fieldformat x = "prefix".CAST(y AS STRING)." suffix"

expands to a chain of CONCAT calls. Both forms route through the existing
CONCAT capability — no extension lookup or adapter needed since isthmus'
default catalog binds the || operator natively.

Four tests against the in-process QA cluster, exercising the analytics path
end-to-end via the test-ppl-frontend plugin:

| Test | Shape |
|---|---|
| `testFieldformatPlusConcat` | `'Hello ' + str0` — basic +-concat. |
| `testFieldformatPrefixDotCast` | `'Code: '.CAST(int0 AS STRING)` — StringDotlogicalExpression branch. |
| `testFieldformatCastDotSuffix` | `CAST(int0 AS STRING).' pts'` — LogicalExpressionDotString branch. |
| `testFieldformatPrefixDotCastDotSuffix` | `'Code: '.CAST(int0 AS STRING).' pts'` — combined. |

Tests filter `where isnotnull(int0)` before sorting/limiting so the
deterministic-row assertions don't flap on the calcs dataset's six null int0
rows (Calcite's default ascending sort puts nulls first).

Out of scope: the v2-side `testFieldFormatStringConcatenationWithNullFieldToString`
uses `tostring(age, "commas")` — a multi-mode UDF (binary / hex / commas /
duration) with substantial Java logic in `ToStringFunction`. Adding it to
the analytics path would need either Calcite-level rewrites or a DataFusion
Rust UDF; tracked separately.

Validates: 4/4 FieldFormatCommandIT pass; full
:sandbox:qa:analytics-engine-rest:integTest suite green
(**132 tests across 17 ITs**, no regressions).

Signed-off-by: Kai Huang <ahkcs@amazon.com>
imRishN pushed a commit to imRishN/OpenSearch that referenced this pull request May 8, 2026
…usion CONCAT/CAST (opensearch-project#21498)

* [Analytics Framework] Resolve symbolic operators and add SAFE_CAST

Calcite emits SqlBinaryOperators (e.g. `||`, the lowering target of PPL
string `+`) with SqlKind.OTHER and a non-identifier name. The existing
ScalarFunction.fromSqlKind / fromSqlFunction pair fails to resolve these:
fromSqlKind misses (OTHER is shared), fromSqlFunction throws because
`||` is not a SqlFunction (it's a SqlBinaryOperator). The planner-side
fallout is "No backend supports scalar function [null] among
[datafusion]" with no useful name in the error.

Introduce ScalarFunction.fromSqlOperator(SqlOperator) — the unified entry
point used by OpenSearchProjectRule, OpenSearchFilterRule, and
BackendPlanAdapter in subsequent commits. Resolution order:

  1. SqlKind via fromSqlKind (covers PLUS, CAST, COALESCE, etc.)
  2. Symbolic-name lookup (handles `||` -> CONCAT)
  3. Identifier-name valueOf fallback (covers UPPER, LOWER, etc.)

The symbolic-name table currently has one entry (`||` -> CONCAT) but is
the documented extension point for future SqlBinaryOperators with non-
identifier names.

Also adds SAFE_CAST as a sibling enum constant to CAST. PPL emits
explicit `CAST(... AS ...)` lowered to Calcite's SqlKind.SAFE_CAST when
the source value may be NULL or the conversion may fail. SAFE_CAST and
CAST share the same backend semantics (DataFusion's native cast already
returns NULL on conversion failure) but resolve through distinct
SqlKinds, so they need distinct enum entries.

Unit test pins all three resolution branches plus the unknown-operator
return-null contract — a regression that drops a branch surfaces here
rather than as an opaque "[null]" IT failure.

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

* [Analytics Engine] Migrate rules and adapter dispatch to fromSqlOperator

Three call sites resolved a RexCall's operator using the same two-step
pattern (SqlKind first, SqlFunction-cast second) and all three failed
identically on `||` (a SqlBinaryOperator with SqlKind.OTHER):

  - OpenSearchProjectRule.resolveScalarViableBackends
  - OpenSearchFilterRule (predicate operator resolution)
  - BackendPlanAdapter.resolveFunction (per-function adapter dispatch)

Migrate all three to ScalarFunction.fromSqlOperator, the unified
resolver added in the previous commit. Behavior for previously-resolved
operators is unchanged — fromSqlOperator delegates to fromSqlKind first,
so anything that resolved through SqlKind continues to. New behavior:
`||` now resolves to CONCAT, and unrecognized operators return null
(catching the IllegalArgumentException that fromSqlFunction's valueOf
threw before; the call sites already handled null and now produce a
better-formed error message that includes the operator name).

Also drop the unused SqlFunction import in OpenSearchFilterRule and
BackendPlanAdapter, and tighten the OpenSearchProjectRule error message
to fall back to operator.getName() when the resolver returns null —
"[null]" was unactionable for triage; "[||]" or "[<unknown_name>]"
points directly at the missing capability.

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

* [Analytics Backend / DataFusion] Wire CONCAT/CAST/SAFE_CAST + concat null adapter

Three new ScalarFunctions in STANDARD_PROJECT_OPS:

  - CONCAT  — lowering target of PPL `eval`'s `+` for strings (Calcite
              emits `||`, resolved to CONCAT through the symbolic-name
              branch of ScalarFunction.fromSqlOperator)
  - CAST    — covers PPL's explicit `CAST(... AS ...)` over non-null
              source types (Calcite emits SqlKind.CAST)
  - SAFE_CAST — same surface, but emitted by Calcite when the source
              value is nullable (SqlKind.SAFE_CAST)

CONCAT additionally needs a ScalarFunctionAdapter to preserve null
semantics. Calcite's `||` follows the SQL standard: if any operand is
NULL, the result is NULL. Substrait's default `concat` extension is
documented with the same semantics, but DataFusion's substrait reader
maps it to the DataFusion `concat()` function — which deviates from the
standard and treats NULL operands as empty strings. PPL queries like
`'Age: ' + CAST(null AS STRING)` expect NULL, not 'Age: '.

ConcatFunctionAdapter rewrites `||(a, b, ...)` into

  CASE WHEN a IS NULL OR b IS NULL OR ... THEN NULL ELSE ||(a, b, ...) END

The inner `||` survives unchanged and serializes through the same
Substrait conversion path; the surrounding CASE/IS_NULL short-circuits
the DataFusion `concat()` call whenever any operand is NULL, restoring
SQL-standard null propagation without a custom DataFusion UDF.

Trade-off: the rewrite double-evaluates each operand (once in IS_NULL,
once in the inner `||`). For RexInputRef and RexLiteral operands —
the only shapes PPL emits today for string concat — this is free; for
nested calls the cost is proportional to operand count, not operand
depth, since each `||` adapter wraps one CASE around its direct call.
A custom null-propagating concat UDF (Bucket-3 work in
sandbox/plugins/analytics-backend-datafusion/rust) is the alternative
but disproportionate for a Bucket-1 surface.

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

* [QA] Add EvalCommandIT for the analytics-engine REST path

Self-contained integration test for PPL `eval` on the analytics-engine
route. Mirrors CalciteEvalCommandIT in opensearch-project/sql so the
analytics-engine path can be verified inside core without cross-plugin
dependencies on the SQL plugin. Each test sends a PPL query through
POST /_analytics/ppl (exposed by test-ppl-frontend) which runs the
same UnifiedQueryPlanner -> CalciteRelNodeVisitor -> Substrait ->
DataFusion pipeline as the SQL plugin's force-routed analytics path.

Four tests on the calcs dataset cover the eval surface this PR enables:

  - testEvalStringConcatLiteralPlusField — `'literal' + str_field`
    exercises the symbolic-name resolution for `||` and the CONCAT
    capability; null str field rows assert null propagation through
    the CASE adapter.
  - testEvalStringConcatWithCastIntField — `'literal' + CAST(int AS STRING)`
    exercises both CAST/SAFE_CAST and CONCAT in the same projection;
    null int rows confirm CAST(NULL) -> NULL propagates through the
    surrounding concat.
  - testEvalStringConcatMultipleLiteralsAndFields — chained four-arg
    concat exercises the recursive AnnotatedProjectExpression strip
    for nested project calls.
  - testEvalStringConcatTwoFields — pure field-to-field concat with
    no literal operands; planner takes the hasFieldRef=true path in
    resolveScalarViableBackends.

Reuses the existing calcs dataset (no new fixtures). Once this lands,
the SQL-plugin's CalciteEvalCommandIT is verification-only — this QA
IT is the source of truth for the analytics-engine path.

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

* [Analytics Framework] Rename fromSqlOperator to fromSqlOperatorWithFallback

Per @expani's PR feedback: the method walks three resolution paths
(SqlKind, symbolic-name table, identifier-name valueOf) before returning
null, so the name should advertise the fallback behavior at the call
site rather than only in the javadoc.

Mechanical rename across all callers — `ScalarFunction.fromSqlOperator`
-> `ScalarFunction.fromSqlOperatorWithFallback` in:
  - the resolver itself plus its 7 unit tests
  - OpenSearchProjectRule (2 call sites)
  - OpenSearchFilterRule (1 call site)
  - BackendPlanAdapter.resolveFunction (1 call site)
  - EvalCommandIT javadoc cross-reference

No behavioral change.

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

* [Analytics Framework + Backend] Address @expani review on PR opensearch-project#21498

Three feedback items in one commit:

1. Co-locate symbolic operator name with the enum constant.
   The static SYMBOLIC_OPERATOR_NAMES map duplicated a property that
   belongs on the enum itself. Moved to a nullable `symbolicOperatorName`
   field on each ScalarFunction constant — currently set only on CONCAT
   ("||"). The reverse-index map is now built from the enum at class-init
   time, so adding a new symbolic operator is a single-site edit on the
   constant rather than a separate map entry.

2. Inline the OR/IS_NULL fold in ConcatFunctionAdapter.
   Drop the temporary List<RexNode> nullChecks and accumulate the
   OR-of-IS_NULLs directly in the loop body. Same generated tree, fewer
   allocations, less to read.

3. Note the Map.of single-line constraint on scalarFunctionAdapters.
   Per-pair formatting is rejected by spotless; left a comment pointing
   future contributors at alphabetical ordering instead, and reordered
   the entries (CONCAT before TIMESTAMP) to make the convention concrete.

No behavioral change. CalciteEvalCommandIT 4/4 still passes against the
analytics-engine route; sandbox per-module check (excluding the
unrelated commons-text dependencyLicenses task) remains green.

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

* [Analytics Framework] Resolve symbolic operators by Calcite-operator reference

Per @expani's PR follow-up: the symbolic-name string ("||") was a
runtime-coupled identifier that could silently drift if Calcite renamed
the operator. Replace it with a direct reference to the Calcite operator
constant (SqlStdOperatorTable.CONCAT), so the link is enforced at
compile time and a Calcite-side rename surfaces as a build failure here.

  - String symbolicOperatorName -> SqlOperator referenceOperator on the
    enum constructor.
  - CONCAT now points at SqlStdOperatorTable.CONCAT instead of "||".
  - Reverse index switches from Map<String, ScalarFunction> keyed by
    operator name to Map<SqlOperator, ScalarFunction> keyed by operator
    identity. Calcite's standard operators are singletons, so identity
    lookup is exact.
  - Unit test renamed (testFromSqlOperatorResolvesPipeConcatViaReferenceOperator)
    and its comment updated; the assertions on `getName()` / `getKind()`
    are kept as documentation of WHY this branch is needed at all.

No behavioral change in the resolution logic — same three-step chain
(SqlKind, then this branch, then identifier-name valueOf), with the
middle branch now identity-comparing rather than name-comparing.

CalciteEvalCommandIT 4/4 still passes; ScalarFunctionTests 7/7.

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

* [Analytics Framework + Backend] Address @expani follow-up on PR opensearch-project#21498

Two feedback items in one commit:

1. Drop the redundant Map.copyOf on BY_REFERENCE_OPERATOR. The HashMap
   built in the static initializer is private static final and is only
   read via the resolver's get() — never returned, never iterated. The
   immutability wrapper added an allocation without conferring any
   external safety guarantee. Comment explains the reasoning so future
   readers don't reintroduce the wrap.

2. Add ConcatFunctionAdapterTests with seven structural assertions on
   the CASE rewrite contract:

   - testAdaptBinaryConcatProducesCaseWrapper: rewritten root is a
     three-operand CASE (condition, then, else).
   - testAdaptedCaseElseBranchIsOriginalConcat: else branch is the
     original RexCall by reference (assertSame, not assertEquals) —
     downstream substrait conversion expects the same object the
     resolver annotated.
   - testAdaptedCaseThenBranchIsNullLiteralOfMatchingSqlType: then
     branch is a NULL literal whose SQL type name matches the original
     CONCAT's. Comment explains why we compare type name rather than
     full RelDataType (RexBuilder.makeNullLiteral promotes nullability,
     so the full types differ harmlessly).
   - testAdaptedCaseConditionIsOrOfIsNullChecks: condition is OR with
     each disjunct an IS_NULL wrapping the corresponding original
     operand at matching index — null-propagation contract is per
     operand.
   - testAdaptPreservesReturnType: full RelDataType identity between
     adapted CASE and original CONCAT — locks the type-preserving
     argument of rexBuilder.makeCall(originalType, CASE, ...).
   - testAdaptNaryConcatChainsIsNullChecksLeftAssociative: builds a
     ternary CONCAT via SqlLibraryOperators.CONCAT_FUNCTION and
     verifies the left-fold structure OR(OR(IS_NULL(a), IS_NULL(b)),
     IS_NULL(c)) — the binary `||` only ever appears with arity 2 in
     production, but the loop's correctness for arbitrary N is now a
     test invariant.
   - testAdaptSingleOperandConcatPassesThroughUnchanged: 1-operand
     call returns input by reference; documents the early-out branch.

   Each test pins one structural property in isolation, so a regression
   that drops any one piece of the contract surfaces with a focused
   failure rather than at IT-level row-mismatch noise.

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

---------

Signed-off-by: Kai Huang <ahkcs@amazon.com>
imRishN pushed a commit to imRishN/OpenSearch that referenced this pull request May 8, 2026
…elds/rename/head/sort (opensearch-project#21521)

Bucket-1 capability-registry expansion for the analytics-engine route — narrow
scope: only the two scalar functions PPL sort push-down materialises into a
projection (ABS, SUBSTRING). Fields / rename / head don't add scalar surface;
they're covered here purely by new QA ITs that lock in the routing-and-shape
behavior end-to-end through `POST /_analytics/ppl`.

After this PR (with the eval-side surface from opensearch-project#21498 already on main):
* `CalciteFieldsCommandIT`, `CalciteRenameCommandIT`, `CalciteHeadCommandIT`
  100% green on the analytics path under `tests.analytics.force_routing=true`.
* `CalciteSortCommandIT` and `CalcitePPLSortIT` pick up the cast / abs /
  substring push-down failures (CAST is already in `STANDARD_PROJECT_OPS`
  upstream from opensearch-project#21476; ABS and SUBSTRING are this PR's contribution).

## Changes

**1. `DataFusionAnalyticsBackendPlugin` — `STANDARD_PROJECT_OPS` += ABS, SUBSTRING.**
PPL sort push-down lifts an expression like `abs(num0)` or `substring(str0, 1, 3)`
into a `LogicalProject` child of the sort, which is what the project rule's
capability check sees. DataFusion has both natively; isthmus' default extension
catalog already binds them. Without this, the analytics planner rejects the
projection with
`No backend supports scalar function [ABS] among [datafusion]`.

**2. QA ITs in `sandbox/qa/analytics-engine-rest`** — one per command, each
self-contained and provisioning the existing `calcs` parquet-backed dataset
via `DatasetProvisioner`. Tests fire through `POST /_analytics/ppl` so the
core build can validate the analytics-engine path without the SQL plugin.
Mirror the failing surface in `CalciteFieldsCommandIT` /
`CalciteRenameCommandIT` / `CalciteHeadCommandIT` /
`CalciteSortCommandIT` one query at a time:

* `FieldsCommandIT` (5 tests) — basic projection, single-column, explicit
  order, suffix-wildcard `*0` (set-equality, since wildcard expansion order
  isn't part of the contract), and `fields - num*` exclusion.
* `RenameCommandIT` (4 tests) — single rename, multi-rename, post-rename
  reference fails with "not found", backtick-quoted target names.
* `HeadCommandIT` (5 tests) — default-10 cap, explicit count, count > total
  rows, `head N from M` offset, and value-equality on the first 5 rows
  (parquet preserves insertion order, so this is deterministic).
* `SortCommandIT` (5 tests) — plain ASC/DESC by integer (with calcs' 6
  null int0 entries placed at the head/tail per Calcite's nulls-first/last
  defaults), `eval n = abs(num0) | sort n` covering the 9 null +
  8 non-null abs values, and `eval s = substring(str2, 1, 3) | sort s`
  validating the SUBSTRING capability end-to-end against the 17-row
  calcs dataset.

## Test plan

* `./gradlew :sandbox:qa:analytics-engine-rest:integTest -Dsandbox.enabled=true
  --tests "*FieldsCommandIT" --tests "*RenameCommandIT" --tests "*HeadCommandIT"
  --tests "*SortCommandIT"` — 19/19 green.
* `./gradlew check -p sandbox -Dsandbox.enabled=true` — green (the unrelated
  `ScalarDateTimeFunctionIT.testConvertTz` flake from a stale local
  `libopensearch_native.dylib` resolved by rebuilding the Rust crate; not
  caused by this PR).
* SQL-plugin Calcite ITs against this branch + companion
  opensearch-project/sql#5413, with
  `-Dtests.analytics.force_routing=true -Dtests.analytics.parquet_indices=true`:
  `CalciteFieldsCommandIT` 39/39, `CalciteRenameCommandIT` 2/2,
  `CalciteHeadCommandIT` 4/4, plus +5 sort-push-down wins in
  `CalciteSortCommandIT` and +1 in `CalcitePPLSortIT` from the ABS / SUBSTRING
  capability additions.

## Out of scope (separate follow-ups)

* `Unable to find binding for call AVG($N)` Substrait-isthmus issue — needs an
  `AggregateSig`-style additional-mappings hook in
  `DataFusionFragmentConvertor`.
* Window functions (`dedup` lowers to `ROW_NUMBER OVER`).
* Advanced aggregates (`first`, `last`, `take`, `arg_max`, `percentile_approx`,
  `distinct_count_approx`) and PPL `span`.
* The eval-predicate surface (`AND`/`OR`/`NOT` in CASE projections,
  `IS_NULL`/`IS_NOT_NULL`, broader string/conditional ops) and STDDEV/VAR
  aggregates — kept out of this PR to keep the scope focused on the four
  commands the QA ITs cover.

Signed-off-by: Kai Huang <ahkcs@amazon.com>
imRishN pushed a commit to imRishN/OpenSearch that referenced this pull request May 8, 2026
…nsearch-project#21544)

PPL `fieldformat` is a Calcite-only command that lowers to a plain Eval node
(see SQL plugin's `AstBuilder.visitFieldformatCommand`). Its expressions go
through Calcite's || (CONCAT) operator and CAST, both already wired in the
DataFusion backend's STANDARD_PROJECT_OPS via opensearch-project#21498. **No code changes
required for the analytics route — this PR is QA-only.**

The unique surface vs plain `eval` is the prefix-{`.`} and suffix-{`.`}
string-concat sugar emitted by `AstExpressionBuilder.visitFieldFormatEvalClause`
for the StringDotlogicalExpression / LogicalExpressionDotString rules:

  fieldformat x = "prefix".CAST(y AS STRING)." suffix"

expands to a chain of CONCAT calls. Both forms route through the existing
CONCAT capability — no extension lookup or adapter needed since isthmus'
default catalog binds the || operator natively.

Four tests against the in-process QA cluster, exercising the analytics path
end-to-end via the test-ppl-frontend plugin:

| Test | Shape |
|---|---|
| `testFieldformatPlusConcat` | `'Hello ' + str0` — basic +-concat. |
| `testFieldformatPrefixDotCast` | `'Code: '.CAST(int0 AS STRING)` — StringDotlogicalExpression branch. |
| `testFieldformatCastDotSuffix` | `CAST(int0 AS STRING).' pts'` — LogicalExpressionDotString branch. |
| `testFieldformatPrefixDotCastDotSuffix` | `'Code: '.CAST(int0 AS STRING).' pts'` — combined. |

Tests filter `where isnotnull(int0)` before sorting/limiting so the
deterministic-row assertions don't flap on the calcs dataset's six null int0
rows (Calcite's default ascending sort puts nulls first).

Out of scope: the v2-side `testFieldFormatStringConcatenationWithNullFieldToString`
uses `tostring(age, "commas")` — a multi-mode UDF (binary / hex / commas /
duration) with substantial Java logic in `ToStringFunction`. Adding it to
the analytics path would need either Calcite-level rewrites or a DataFusion
Rust UDF; tracked separately.

Validates: 4/4 FieldFormatCommandIT pass; full
:sandbox:qa:analytics-engine-rest:integTest suite green
(**132 tests across 17 ITs**, no regressions).

Signed-off-by: Kai Huang <ahkcs@amazon.com>
vishwasgarg18 pushed a commit to vishwasgarg18/OpenSearch that referenced this pull request May 8, 2026
…usion CONCAT/CAST (opensearch-project#21498)

* [Analytics Framework] Resolve symbolic operators and add SAFE_CAST

Calcite emits SqlBinaryOperators (e.g. `||`, the lowering target of PPL
string `+`) with SqlKind.OTHER and a non-identifier name. The existing
ScalarFunction.fromSqlKind / fromSqlFunction pair fails to resolve these:
fromSqlKind misses (OTHER is shared), fromSqlFunction throws because
`||` is not a SqlFunction (it's a SqlBinaryOperator). The planner-side
fallout is "No backend supports scalar function [null] among
[datafusion]" with no useful name in the error.

Introduce ScalarFunction.fromSqlOperator(SqlOperator) — the unified entry
point used by OpenSearchProjectRule, OpenSearchFilterRule, and
BackendPlanAdapter in subsequent commits. Resolution order:

  1. SqlKind via fromSqlKind (covers PLUS, CAST, COALESCE, etc.)
  2. Symbolic-name lookup (handles `||` -> CONCAT)
  3. Identifier-name valueOf fallback (covers UPPER, LOWER, etc.)

The symbolic-name table currently has one entry (`||` -> CONCAT) but is
the documented extension point for future SqlBinaryOperators with non-
identifier names.

Also adds SAFE_CAST as a sibling enum constant to CAST. PPL emits
explicit `CAST(... AS ...)` lowered to Calcite's SqlKind.SAFE_CAST when
the source value may be NULL or the conversion may fail. SAFE_CAST and
CAST share the same backend semantics (DataFusion's native cast already
returns NULL on conversion failure) but resolve through distinct
SqlKinds, so they need distinct enum entries.

Unit test pins all three resolution branches plus the unknown-operator
return-null contract — a regression that drops a branch surfaces here
rather than as an opaque "[null]" IT failure.

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

* [Analytics Engine] Migrate rules and adapter dispatch to fromSqlOperator

Three call sites resolved a RexCall's operator using the same two-step
pattern (SqlKind first, SqlFunction-cast second) and all three failed
identically on `||` (a SqlBinaryOperator with SqlKind.OTHER):

  - OpenSearchProjectRule.resolveScalarViableBackends
  - OpenSearchFilterRule (predicate operator resolution)
  - BackendPlanAdapter.resolveFunction (per-function adapter dispatch)

Migrate all three to ScalarFunction.fromSqlOperator, the unified
resolver added in the previous commit. Behavior for previously-resolved
operators is unchanged — fromSqlOperator delegates to fromSqlKind first,
so anything that resolved through SqlKind continues to. New behavior:
`||` now resolves to CONCAT, and unrecognized operators return null
(catching the IllegalArgumentException that fromSqlFunction's valueOf
threw before; the call sites already handled null and now produce a
better-formed error message that includes the operator name).

Also drop the unused SqlFunction import in OpenSearchFilterRule and
BackendPlanAdapter, and tighten the OpenSearchProjectRule error message
to fall back to operator.getName() when the resolver returns null —
"[null]" was unactionable for triage; "[||]" or "[<unknown_name>]"
points directly at the missing capability.

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

* [Analytics Backend / DataFusion] Wire CONCAT/CAST/SAFE_CAST + concat null adapter

Three new ScalarFunctions in STANDARD_PROJECT_OPS:

  - CONCAT  — lowering target of PPL `eval`'s `+` for strings (Calcite
              emits `||`, resolved to CONCAT through the symbolic-name
              branch of ScalarFunction.fromSqlOperator)
  - CAST    — covers PPL's explicit `CAST(... AS ...)` over non-null
              source types (Calcite emits SqlKind.CAST)
  - SAFE_CAST — same surface, but emitted by Calcite when the source
              value is nullable (SqlKind.SAFE_CAST)

CONCAT additionally needs a ScalarFunctionAdapter to preserve null
semantics. Calcite's `||` follows the SQL standard: if any operand is
NULL, the result is NULL. Substrait's default `concat` extension is
documented with the same semantics, but DataFusion's substrait reader
maps it to the DataFusion `concat()` function — which deviates from the
standard and treats NULL operands as empty strings. PPL queries like
`'Age: ' + CAST(null AS STRING)` expect NULL, not 'Age: '.

ConcatFunctionAdapter rewrites `||(a, b, ...)` into

  CASE WHEN a IS NULL OR b IS NULL OR ... THEN NULL ELSE ||(a, b, ...) END

The inner `||` survives unchanged and serializes through the same
Substrait conversion path; the surrounding CASE/IS_NULL short-circuits
the DataFusion `concat()` call whenever any operand is NULL, restoring
SQL-standard null propagation without a custom DataFusion UDF.

Trade-off: the rewrite double-evaluates each operand (once in IS_NULL,
once in the inner `||`). For RexInputRef and RexLiteral operands —
the only shapes PPL emits today for string concat — this is free; for
nested calls the cost is proportional to operand count, not operand
depth, since each `||` adapter wraps one CASE around its direct call.
A custom null-propagating concat UDF (Bucket-3 work in
sandbox/plugins/analytics-backend-datafusion/rust) is the alternative
but disproportionate for a Bucket-1 surface.

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

* [QA] Add EvalCommandIT for the analytics-engine REST path

Self-contained integration test for PPL `eval` on the analytics-engine
route. Mirrors CalciteEvalCommandIT in opensearch-project/sql so the
analytics-engine path can be verified inside core without cross-plugin
dependencies on the SQL plugin. Each test sends a PPL query through
POST /_analytics/ppl (exposed by test-ppl-frontend) which runs the
same UnifiedQueryPlanner -> CalciteRelNodeVisitor -> Substrait ->
DataFusion pipeline as the SQL plugin's force-routed analytics path.

Four tests on the calcs dataset cover the eval surface this PR enables:

  - testEvalStringConcatLiteralPlusField — `'literal' + str_field`
    exercises the symbolic-name resolution for `||` and the CONCAT
    capability; null str field rows assert null propagation through
    the CASE adapter.
  - testEvalStringConcatWithCastIntField — `'literal' + CAST(int AS STRING)`
    exercises both CAST/SAFE_CAST and CONCAT in the same projection;
    null int rows confirm CAST(NULL) -> NULL propagates through the
    surrounding concat.
  - testEvalStringConcatMultipleLiteralsAndFields — chained four-arg
    concat exercises the recursive AnnotatedProjectExpression strip
    for nested project calls.
  - testEvalStringConcatTwoFields — pure field-to-field concat with
    no literal operands; planner takes the hasFieldRef=true path in
    resolveScalarViableBackends.

Reuses the existing calcs dataset (no new fixtures). Once this lands,
the SQL-plugin's CalciteEvalCommandIT is verification-only — this QA
IT is the source of truth for the analytics-engine path.

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

* [Analytics Framework] Rename fromSqlOperator to fromSqlOperatorWithFallback

Per @expani's PR feedback: the method walks three resolution paths
(SqlKind, symbolic-name table, identifier-name valueOf) before returning
null, so the name should advertise the fallback behavior at the call
site rather than only in the javadoc.

Mechanical rename across all callers — `ScalarFunction.fromSqlOperator`
-> `ScalarFunction.fromSqlOperatorWithFallback` in:
  - the resolver itself plus its 7 unit tests
  - OpenSearchProjectRule (2 call sites)
  - OpenSearchFilterRule (1 call site)
  - BackendPlanAdapter.resolveFunction (1 call site)
  - EvalCommandIT javadoc cross-reference

No behavioral change.

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

* [Analytics Framework + Backend] Address @expani review on PR opensearch-project#21498

Three feedback items in one commit:

1. Co-locate symbolic operator name with the enum constant.
   The static SYMBOLIC_OPERATOR_NAMES map duplicated a property that
   belongs on the enum itself. Moved to a nullable `symbolicOperatorName`
   field on each ScalarFunction constant — currently set only on CONCAT
   ("||"). The reverse-index map is now built from the enum at class-init
   time, so adding a new symbolic operator is a single-site edit on the
   constant rather than a separate map entry.

2. Inline the OR/IS_NULL fold in ConcatFunctionAdapter.
   Drop the temporary List<RexNode> nullChecks and accumulate the
   OR-of-IS_NULLs directly in the loop body. Same generated tree, fewer
   allocations, less to read.

3. Note the Map.of single-line constraint on scalarFunctionAdapters.
   Per-pair formatting is rejected by spotless; left a comment pointing
   future contributors at alphabetical ordering instead, and reordered
   the entries (CONCAT before TIMESTAMP) to make the convention concrete.

No behavioral change. CalciteEvalCommandIT 4/4 still passes against the
analytics-engine route; sandbox per-module check (excluding the
unrelated commons-text dependencyLicenses task) remains green.

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

* [Analytics Framework] Resolve symbolic operators by Calcite-operator reference

Per @expani's PR follow-up: the symbolic-name string ("||") was a
runtime-coupled identifier that could silently drift if Calcite renamed
the operator. Replace it with a direct reference to the Calcite operator
constant (SqlStdOperatorTable.CONCAT), so the link is enforced at
compile time and a Calcite-side rename surfaces as a build failure here.

  - String symbolicOperatorName -> SqlOperator referenceOperator on the
    enum constructor.
  - CONCAT now points at SqlStdOperatorTable.CONCAT instead of "||".
  - Reverse index switches from Map<String, ScalarFunction> keyed by
    operator name to Map<SqlOperator, ScalarFunction> keyed by operator
    identity. Calcite's standard operators are singletons, so identity
    lookup is exact.
  - Unit test renamed (testFromSqlOperatorResolvesPipeConcatViaReferenceOperator)
    and its comment updated; the assertions on `getName()` / `getKind()`
    are kept as documentation of WHY this branch is needed at all.

No behavioral change in the resolution logic — same three-step chain
(SqlKind, then this branch, then identifier-name valueOf), with the
middle branch now identity-comparing rather than name-comparing.

CalciteEvalCommandIT 4/4 still passes; ScalarFunctionTests 7/7.

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

* [Analytics Framework + Backend] Address @expani follow-up on PR opensearch-project#21498

Two feedback items in one commit:

1. Drop the redundant Map.copyOf on BY_REFERENCE_OPERATOR. The HashMap
   built in the static initializer is private static final and is only
   read via the resolver's get() — never returned, never iterated. The
   immutability wrapper added an allocation without conferring any
   external safety guarantee. Comment explains the reasoning so future
   readers don't reintroduce the wrap.

2. Add ConcatFunctionAdapterTests with seven structural assertions on
   the CASE rewrite contract:

   - testAdaptBinaryConcatProducesCaseWrapper: rewritten root is a
     three-operand CASE (condition, then, else).
   - testAdaptedCaseElseBranchIsOriginalConcat: else branch is the
     original RexCall by reference (assertSame, not assertEquals) —
     downstream substrait conversion expects the same object the
     resolver annotated.
   - testAdaptedCaseThenBranchIsNullLiteralOfMatchingSqlType: then
     branch is a NULL literal whose SQL type name matches the original
     CONCAT's. Comment explains why we compare type name rather than
     full RelDataType (RexBuilder.makeNullLiteral promotes nullability,
     so the full types differ harmlessly).
   - testAdaptedCaseConditionIsOrOfIsNullChecks: condition is OR with
     each disjunct an IS_NULL wrapping the corresponding original
     operand at matching index — null-propagation contract is per
     operand.
   - testAdaptPreservesReturnType: full RelDataType identity between
     adapted CASE and original CONCAT — locks the type-preserving
     argument of rexBuilder.makeCall(originalType, CASE, ...).
   - testAdaptNaryConcatChainsIsNullChecksLeftAssociative: builds a
     ternary CONCAT via SqlLibraryOperators.CONCAT_FUNCTION and
     verifies the left-fold structure OR(OR(IS_NULL(a), IS_NULL(b)),
     IS_NULL(c)) — the binary `||` only ever appears with arity 2 in
     production, but the loop's correctness for arbitrary N is now a
     test invariant.
   - testAdaptSingleOperandConcatPassesThroughUnchanged: 1-operand
     call returns input by reference; documents the early-out branch.

   Each test pins one structural property in isolation, so a regression
   that drops any one piece of the contract surfaces with a focused
   failure rather than at IT-level row-mismatch noise.

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

---------

Signed-off-by: Kai Huang <ahkcs@amazon.com>
vishwasgarg18 pushed a commit to vishwasgarg18/OpenSearch that referenced this pull request May 8, 2026
…elds/rename/head/sort (opensearch-project#21521)

Bucket-1 capability-registry expansion for the analytics-engine route — narrow
scope: only the two scalar functions PPL sort push-down materialises into a
projection (ABS, SUBSTRING). Fields / rename / head don't add scalar surface;
they're covered here purely by new QA ITs that lock in the routing-and-shape
behavior end-to-end through `POST /_analytics/ppl`.

After this PR (with the eval-side surface from opensearch-project#21498 already on main):
* `CalciteFieldsCommandIT`, `CalciteRenameCommandIT`, `CalciteHeadCommandIT`
  100% green on the analytics path under `tests.analytics.force_routing=true`.
* `CalciteSortCommandIT` and `CalcitePPLSortIT` pick up the cast / abs /
  substring push-down failures (CAST is already in `STANDARD_PROJECT_OPS`
  upstream from opensearch-project#21476; ABS and SUBSTRING are this PR's contribution).

## Changes

**1. `DataFusionAnalyticsBackendPlugin` — `STANDARD_PROJECT_OPS` += ABS, SUBSTRING.**
PPL sort push-down lifts an expression like `abs(num0)` or `substring(str0, 1, 3)`
into a `LogicalProject` child of the sort, which is what the project rule's
capability check sees. DataFusion has both natively; isthmus' default extension
catalog already binds them. Without this, the analytics planner rejects the
projection with
`No backend supports scalar function [ABS] among [datafusion]`.

**2. QA ITs in `sandbox/qa/analytics-engine-rest`** — one per command, each
self-contained and provisioning the existing `calcs` parquet-backed dataset
via `DatasetProvisioner`. Tests fire through `POST /_analytics/ppl` so the
core build can validate the analytics-engine path without the SQL plugin.
Mirror the failing surface in `CalciteFieldsCommandIT` /
`CalciteRenameCommandIT` / `CalciteHeadCommandIT` /
`CalciteSortCommandIT` one query at a time:

* `FieldsCommandIT` (5 tests) — basic projection, single-column, explicit
  order, suffix-wildcard `*0` (set-equality, since wildcard expansion order
  isn't part of the contract), and `fields - num*` exclusion.
* `RenameCommandIT` (4 tests) — single rename, multi-rename, post-rename
  reference fails with "not found", backtick-quoted target names.
* `HeadCommandIT` (5 tests) — default-10 cap, explicit count, count > total
  rows, `head N from M` offset, and value-equality on the first 5 rows
  (parquet preserves insertion order, so this is deterministic).
* `SortCommandIT` (5 tests) — plain ASC/DESC by integer (with calcs' 6
  null int0 entries placed at the head/tail per Calcite's nulls-first/last
  defaults), `eval n = abs(num0) | sort n` covering the 9 null +
  8 non-null abs values, and `eval s = substring(str2, 1, 3) | sort s`
  validating the SUBSTRING capability end-to-end against the 17-row
  calcs dataset.

## Test plan

* `./gradlew :sandbox:qa:analytics-engine-rest:integTest -Dsandbox.enabled=true
  --tests "*FieldsCommandIT" --tests "*RenameCommandIT" --tests "*HeadCommandIT"
  --tests "*SortCommandIT"` — 19/19 green.
* `./gradlew check -p sandbox -Dsandbox.enabled=true` — green (the unrelated
  `ScalarDateTimeFunctionIT.testConvertTz` flake from a stale local
  `libopensearch_native.dylib` resolved by rebuilding the Rust crate; not
  caused by this PR).
* SQL-plugin Calcite ITs against this branch + companion
  opensearch-project/sql#5413, with
  `-Dtests.analytics.force_routing=true -Dtests.analytics.parquet_indices=true`:
  `CalciteFieldsCommandIT` 39/39, `CalciteRenameCommandIT` 2/2,
  `CalciteHeadCommandIT` 4/4, plus +5 sort-push-down wins in
  `CalciteSortCommandIT` and +1 in `CalcitePPLSortIT` from the ABS / SUBSTRING
  capability additions.

## Out of scope (separate follow-ups)

* `Unable to find binding for call AVG($N)` Substrait-isthmus issue — needs an
  `AggregateSig`-style additional-mappings hook in
  `DataFusionFragmentConvertor`.
* Window functions (`dedup` lowers to `ROW_NUMBER OVER`).
* Advanced aggregates (`first`, `last`, `take`, `arg_max`, `percentile_approx`,
  `distinct_count_approx`) and PPL `span`.
* The eval-predicate surface (`AND`/`OR`/`NOT` in CASE projections,
  `IS_NULL`/`IS_NOT_NULL`, broader string/conditional ops) and STDDEV/VAR
  aggregates — kept out of this PR to keep the scope focused on the four
  commands the QA ITs cover.

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

PPL `fieldformat` is a Calcite-only command that lowers to a plain Eval node
(see SQL plugin's `AstBuilder.visitFieldformatCommand`). Its expressions go
through Calcite's || (CONCAT) operator and CAST, both already wired in the
DataFusion backend's STANDARD_PROJECT_OPS via opensearch-project#21498. **No code changes
required for the analytics route — this PR is QA-only.**

The unique surface vs plain `eval` is the prefix-{`.`} and suffix-{`.`}
string-concat sugar emitted by `AstExpressionBuilder.visitFieldFormatEvalClause`
for the StringDotlogicalExpression / LogicalExpressionDotString rules:

  fieldformat x = "prefix".CAST(y AS STRING)." suffix"

expands to a chain of CONCAT calls. Both forms route through the existing
CONCAT capability — no extension lookup or adapter needed since isthmus'
default catalog binds the || operator natively.

Four tests against the in-process QA cluster, exercising the analytics path
end-to-end via the test-ppl-frontend plugin:

| Test | Shape |
|---|---|
| `testFieldformatPlusConcat` | `'Hello ' + str0` — basic +-concat. |
| `testFieldformatPrefixDotCast` | `'Code: '.CAST(int0 AS STRING)` — StringDotlogicalExpression branch. |
| `testFieldformatCastDotSuffix` | `CAST(int0 AS STRING).' pts'` — LogicalExpressionDotString branch. |
| `testFieldformatPrefixDotCastDotSuffix` | `'Code: '.CAST(int0 AS STRING).' pts'` — combined. |

Tests filter `where isnotnull(int0)` before sorting/limiting so the
deterministic-row assertions don't flap on the calcs dataset's six null int0
rows (Calcite's default ascending sort puts nulls first).

Out of scope: the v2-side `testFieldFormatStringConcatenationWithNullFieldToString`
uses `tostring(age, "commas")` — a multi-mode UDF (binary / hex / commas /
duration) with substantial Java logic in `ToStringFunction`. Adding it to
the analytics path would need either Calcite-level rewrites or a DataFusion
Rust UDF; tracked separately.

Validates: 4/4 FieldFormatCommandIT pass; full
:sandbox:qa:analytics-engine-rest:integTest suite green
(**132 tests across 17 ITs**, no regressions).

Signed-off-by: Kai Huang <ahkcs@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.

3 participants