From 0d2dbe17f898ff2d40ede6e3e89b44a3c3e9c64f Mon Sep 17 00:00:00 2001 From: Chen Dai Date: Fri, 8 May 2026 14:34:41 -0700 Subject: [PATCH] feat(sql): Support ARRAY[] multi-field syntax for relevance functions Add support for standard SQL ARRAY syntax in multi-field relevance functions (multi_match, simple_query_string, query_string). The NamedArgRewriter expands ARRAY['f1','f2'] into a MAP with VARCHAR-typed field names and default weight 1.0, producing plans compatible with the Analytics engine pushdown rules. Syntax: multi_match(ARRAY['name', 'department'], 'John') The key technique is wrapping each field literal in CAST(... AS VARCHAR) at the SqlNode level so Calcite's validator produces bare RexLiterals without type-widening CASTs in the final plan. Signed-off-by: Chen Dai --- .../sql/api/spec/search/NamedArgRewriter.java | 69 +++++++++++++++---- .../api/UnifiedRelevanceSearchSqlTest.java | 36 ++++++++++ 2 files changed, 92 insertions(+), 13 deletions(-) diff --git a/api/src/main/java/org/opensearch/sql/api/spec/search/NamedArgRewriter.java b/api/src/main/java/org/opensearch/sql/api/spec/search/NamedArgRewriter.java index 8627a76f2cf..d4b8b4d8b51 100644 --- a/api/src/main/java/org/opensearch/sql/api/spec/search/NamedArgRewriter.java +++ b/api/src/main/java/org/opensearch/sql/api/spec/search/NamedArgRewriter.java @@ -5,16 +5,24 @@ package org.opensearch.sql.api.spec.search; +import static org.apache.calcite.sql.fun.SqlStdOperatorTable.ARRAY_VALUE_CONSTRUCTOR; +import static org.apache.calcite.sql.fun.SqlStdOperatorTable.CAST; +import static org.apache.calcite.sql.fun.SqlStdOperatorTable.MAP_VALUE_CONSTRUCTOR; +import static org.apache.calcite.sql.type.SqlTypeName.VARCHAR; + +import java.util.ArrayList; import java.util.List; import lombok.AccessLevel; import lombok.NoArgsConstructor; +import org.apache.calcite.sql.SqlBasicTypeNameSpec; import org.apache.calcite.sql.SqlCall; +import org.apache.calcite.sql.SqlDataTypeSpec; import org.apache.calcite.sql.SqlIdentifier; import org.apache.calcite.sql.SqlKind; import org.apache.calcite.sql.SqlLiteral; import org.apache.calcite.sql.SqlNode; -import org.apache.calcite.sql.fun.SqlStdOperatorTable; import org.apache.calcite.sql.parser.SqlParserPos; +import org.apache.calcite.sql.type.SqlTypeName; import org.apache.calcite.sql.util.SqlShuttle; import org.checkerframework.checker.nullness.qual.Nullable; import org.opensearch.sql.api.spec.UnifiedFunctionSpec; @@ -42,34 +50,69 @@ public final class NamedArgRewriter extends SqlShuttle { /** * Rewrites each argument into a MAP entry. For match(name, 'John', operator='AND'): - *
  • Positional arg: name → MAP('field', name) *
  • Named arg: operator='AND' → MAP('operator', 'AND') + *
  • Positional arg: name → MAP('field', name) + *
  • ARRAY arg: ARRAY['f1','f2'] → MAP('fields', MAP(CAST('f1' AS VARCHAR), 1, ...)) */ private static SqlCall rewriteToMaps(SqlCall call, List paramNames) { List operands = call.getOperandList(); SqlNode[] maps = new SqlNode[operands.size()]; for (int i = 0; i < operands.size(); i++) { SqlNode op = operands.get(i); - if (op instanceof SqlCall eq && op.getKind() == SqlKind.EQUALS) { - SqlNode key = eq.operand(0); - String name = - key instanceof SqlIdentifier ident - ? ident.getSimple() - : key.toString(); // avoid backtick-decorated keys for reserved words - maps[i] = toMap(name, eq.operand(1)); - } else { + if (isNamedArg(op)) { + maps[i] = namedArgToMap((SqlCall) op); + } else { // Positional arg if (i >= paramNames.size()) { throw new IllegalArgumentException( String.format("Invalid arguments for function '%s'", call.getOperator().getName())); + } else if (isArrayArg(op)) { + maps[i] = map(paramNames.get(i), arrayArgToMap((SqlCall) op)); + } else { + maps[i] = map(paramNames.get(i), op); } - maps[i] = toMap(paramNames.get(i), op); } } return call.getOperator().createCall(call.getParserPosition(), maps); } - private static SqlNode toMap(String key, SqlNode value) { - return SqlStdOperatorTable.MAP_VALUE_CONSTRUCTOR.createCall( + private static boolean isNamedArg(SqlNode node) { + return node instanceof SqlCall && node.getKind() == SqlKind.EQUALS; + } + + private static boolean isArrayArg(SqlNode node) { + return node instanceof SqlCall call && call.getOperator() == ARRAY_VALUE_CONSTRUCTOR; + } + + private static SqlNode namedArgToMap(SqlCall eq) { + SqlNode key = eq.operand(0); + String name = + key instanceof SqlIdentifier ident + ? ident.getSimple() + : key.toString(); // avoid backtick-decorated keys for reserved words + return map(name, eq.operand(1)); + } + + private static SqlNode arrayArgToMap(SqlCall arrayCall) { + List mapArgs = new ArrayList<>(); + for (SqlNode element : arrayCall.getOperandList()) { + mapArgs.add(cast(element, VARCHAR)); + mapArgs.add(SqlLiteral.createApproxNumeric("1.0", SqlParserPos.ZERO)); + } + return map(mapArgs); + } + + private static SqlNode cast(SqlNode node, SqlTypeName type) { + SqlDataTypeSpec typeSpec = + new SqlDataTypeSpec(new SqlBasicTypeNameSpec(type, SqlParserPos.ZERO), SqlParserPos.ZERO); + return CAST.createCall(SqlParserPos.ZERO, node, typeSpec); + } + + private static SqlNode map(String key, SqlNode value) { + return MAP_VALUE_CONSTRUCTOR.createCall( SqlParserPos.ZERO, SqlLiteral.createCharString(key, SqlParserPos.ZERO), value); } + + private static SqlNode map(List kvPairs) { + return MAP_VALUE_CONSTRUCTOR.createCall(SqlParserPos.ZERO, kvPairs.toArray(SqlNode[]::new)); + } } diff --git a/api/src/test/java/org/opensearch/sql/api/UnifiedRelevanceSearchSqlTest.java b/api/src/test/java/org/opensearch/sql/api/UnifiedRelevanceSearchSqlTest.java index 66df9c2e075..3a12405e636 100644 --- a/api/src/test/java/org/opensearch/sql/api/UnifiedRelevanceSearchSqlTest.java +++ b/api/src/test/java/org/opensearch/sql/api/UnifiedRelevanceSearchSqlTest.java @@ -145,6 +145,42 @@ SELECT upper(name) FROM catalog.employees\ // FIXME: Calcite's SQL parser does not support V2 bracket field list syntax ['field1', 'field2']. // Multi-field relevance functions only accept a single column reference in the Calcite SQL path. + @Test + public void testMultiMatchArraySyntax() { + givenQuery( + """ + SELECT * FROM catalog.employees + WHERE multi_match(ARRAY['name', 'department'], 'John')\ + """) + .assertPlanContains( + "multi_match(MAP('fields', MAP('name':VARCHAR, 1.0E0:DOUBLE," + + " 'department':VARCHAR, 1.0E0:DOUBLE)), MAP('query', 'John'))"); + } + + @Test + public void testSimpleQueryStringArraySyntax() { + givenQuery( + """ + SELECT * FROM catalog.employees + WHERE simple_query_string(ARRAY['name', 'department'], 'John')\ + """) + .assertPlanContains( + "simple_query_string(MAP('fields', MAP('name':VARCHAR, 1.0E0:DOUBLE," + + " 'department':VARCHAR, 1.0E0:DOUBLE)), MAP('query', 'John'))"); + } + + @Test + public void testQueryStringArraySyntax() { + givenQuery( + """ + SELECT * FROM catalog.employees + WHERE query_string(ARRAY['name', 'department'], 'John')\ + """) + .assertPlanContains( + "query_string(MAP('fields', MAP('name':VARCHAR, 1.0E0:DOUBLE," + + " 'department':VARCHAR, 1.0E0:DOUBLE)), MAP('query', 'John'))"); + } + @Test public void testMultiMatchBracketSyntaxNotSupported() { givenInvalidQuery(