Skip to content

Commit 0d2dbe1

Browse files
committed
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 <daichen@amazon.com>
1 parent 0ff1eec commit 0d2dbe1

2 files changed

Lines changed: 92 additions & 13 deletions

File tree

api/src/main/java/org/opensearch/sql/api/spec/search/NamedArgRewriter.java

Lines changed: 56 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,24 @@
55

66
package org.opensearch.sql.api.spec.search;
77

8+
import static org.apache.calcite.sql.fun.SqlStdOperatorTable.ARRAY_VALUE_CONSTRUCTOR;
9+
import static org.apache.calcite.sql.fun.SqlStdOperatorTable.CAST;
10+
import static org.apache.calcite.sql.fun.SqlStdOperatorTable.MAP_VALUE_CONSTRUCTOR;
11+
import static org.apache.calcite.sql.type.SqlTypeName.VARCHAR;
12+
13+
import java.util.ArrayList;
814
import java.util.List;
915
import lombok.AccessLevel;
1016
import lombok.NoArgsConstructor;
17+
import org.apache.calcite.sql.SqlBasicTypeNameSpec;
1118
import org.apache.calcite.sql.SqlCall;
19+
import org.apache.calcite.sql.SqlDataTypeSpec;
1220
import org.apache.calcite.sql.SqlIdentifier;
1321
import org.apache.calcite.sql.SqlKind;
1422
import org.apache.calcite.sql.SqlLiteral;
1523
import org.apache.calcite.sql.SqlNode;
16-
import org.apache.calcite.sql.fun.SqlStdOperatorTable;
1724
import org.apache.calcite.sql.parser.SqlParserPos;
25+
import org.apache.calcite.sql.type.SqlTypeName;
1826
import org.apache.calcite.sql.util.SqlShuttle;
1927
import org.checkerframework.checker.nullness.qual.Nullable;
2028
import org.opensearch.sql.api.spec.UnifiedFunctionSpec;
@@ -42,34 +50,69 @@ public final class NamedArgRewriter extends SqlShuttle {
4250

4351
/**
4452
* Rewrites each argument into a MAP entry. For match(name, 'John', operator='AND'):
45-
* <li>Positional arg: name → MAP('field', name)
4653
* <li>Named arg: operator='AND' → MAP('operator', 'AND')
54+
* <li>Positional arg: name → MAP('field', name)
55+
* <li>ARRAY arg: ARRAY['f1','f2'] → MAP('fields', MAP(CAST('f1' AS VARCHAR), 1, ...))
4756
*/
4857
private static SqlCall rewriteToMaps(SqlCall call, List<String> paramNames) {
4958
List<SqlNode> operands = call.getOperandList();
5059
SqlNode[] maps = new SqlNode[operands.size()];
5160
for (int i = 0; i < operands.size(); i++) {
5261
SqlNode op = operands.get(i);
53-
if (op instanceof SqlCall eq && op.getKind() == SqlKind.EQUALS) {
54-
SqlNode key = eq.operand(0);
55-
String name =
56-
key instanceof SqlIdentifier ident
57-
? ident.getSimple()
58-
: key.toString(); // avoid backtick-decorated keys for reserved words
59-
maps[i] = toMap(name, eq.operand(1));
60-
} else {
62+
if (isNamedArg(op)) {
63+
maps[i] = namedArgToMap((SqlCall) op);
64+
} else { // Positional arg
6165
if (i >= paramNames.size()) {
6266
throw new IllegalArgumentException(
6367
String.format("Invalid arguments for function '%s'", call.getOperator().getName()));
68+
} else if (isArrayArg(op)) {
69+
maps[i] = map(paramNames.get(i), arrayArgToMap((SqlCall) op));
70+
} else {
71+
maps[i] = map(paramNames.get(i), op);
6472
}
65-
maps[i] = toMap(paramNames.get(i), op);
6673
}
6774
}
6875
return call.getOperator().createCall(call.getParserPosition(), maps);
6976
}
7077

71-
private static SqlNode toMap(String key, SqlNode value) {
72-
return SqlStdOperatorTable.MAP_VALUE_CONSTRUCTOR.createCall(
78+
private static boolean isNamedArg(SqlNode node) {
79+
return node instanceof SqlCall && node.getKind() == SqlKind.EQUALS;
80+
}
81+
82+
private static boolean isArrayArg(SqlNode node) {
83+
return node instanceof SqlCall call && call.getOperator() == ARRAY_VALUE_CONSTRUCTOR;
84+
}
85+
86+
private static SqlNode namedArgToMap(SqlCall eq) {
87+
SqlNode key = eq.operand(0);
88+
String name =
89+
key instanceof SqlIdentifier ident
90+
? ident.getSimple()
91+
: key.toString(); // avoid backtick-decorated keys for reserved words
92+
return map(name, eq.operand(1));
93+
}
94+
95+
private static SqlNode arrayArgToMap(SqlCall arrayCall) {
96+
List<SqlNode> mapArgs = new ArrayList<>();
97+
for (SqlNode element : arrayCall.getOperandList()) {
98+
mapArgs.add(cast(element, VARCHAR));
99+
mapArgs.add(SqlLiteral.createApproxNumeric("1.0", SqlParserPos.ZERO));
100+
}
101+
return map(mapArgs);
102+
}
103+
104+
private static SqlNode cast(SqlNode node, SqlTypeName type) {
105+
SqlDataTypeSpec typeSpec =
106+
new SqlDataTypeSpec(new SqlBasicTypeNameSpec(type, SqlParserPos.ZERO), SqlParserPos.ZERO);
107+
return CAST.createCall(SqlParserPos.ZERO, node, typeSpec);
108+
}
109+
110+
private static SqlNode map(String key, SqlNode value) {
111+
return MAP_VALUE_CONSTRUCTOR.createCall(
73112
SqlParserPos.ZERO, SqlLiteral.createCharString(key, SqlParserPos.ZERO), value);
74113
}
114+
115+
private static SqlNode map(List<SqlNode> kvPairs) {
116+
return MAP_VALUE_CONSTRUCTOR.createCall(SqlParserPos.ZERO, kvPairs.toArray(SqlNode[]::new));
117+
}
75118
}

api/src/test/java/org/opensearch/sql/api/UnifiedRelevanceSearchSqlTest.java

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,42 @@ SELECT upper(name) FROM catalog.employees\
145145
// FIXME: Calcite's SQL parser does not support V2 bracket field list syntax ['field1', 'field2'].
146146
// Multi-field relevance functions only accept a single column reference in the Calcite SQL path.
147147

148+
@Test
149+
public void testMultiMatchArraySyntax() {
150+
givenQuery(
151+
"""
152+
SELECT * FROM catalog.employees
153+
WHERE multi_match(ARRAY['name', 'department'], 'John')\
154+
""")
155+
.assertPlanContains(
156+
"multi_match(MAP('fields', MAP('name':VARCHAR, 1.0E0:DOUBLE,"
157+
+ " 'department':VARCHAR, 1.0E0:DOUBLE)), MAP('query', 'John'))");
158+
}
159+
160+
@Test
161+
public void testSimpleQueryStringArraySyntax() {
162+
givenQuery(
163+
"""
164+
SELECT * FROM catalog.employees
165+
WHERE simple_query_string(ARRAY['name', 'department'], 'John')\
166+
""")
167+
.assertPlanContains(
168+
"simple_query_string(MAP('fields', MAP('name':VARCHAR, 1.0E0:DOUBLE,"
169+
+ " 'department':VARCHAR, 1.0E0:DOUBLE)), MAP('query', 'John'))");
170+
}
171+
172+
@Test
173+
public void testQueryStringArraySyntax() {
174+
givenQuery(
175+
"""
176+
SELECT * FROM catalog.employees
177+
WHERE query_string(ARRAY['name', 'department'], 'John')\
178+
""")
179+
.assertPlanContains(
180+
"query_string(MAP('fields', MAP('name':VARCHAR, 1.0E0:DOUBLE,"
181+
+ " 'department':VARCHAR, 1.0E0:DOUBLE)), MAP('query', 'John'))");
182+
}
183+
148184
@Test
149185
public void testMultiMatchBracketSyntaxNotSupported() {
150186
givenInvalidQuery(

0 commit comments

Comments
 (0)