Skip to content

Commit 8e54956

Browse files
committed
Register relevance search functions in Calcite SQL planner
Register 7 PPL search function operators (match, match_phrase, match_bool_prefix, match_phrase_prefix, simple_query_string, query_string, multi_match) into the Calcite SQL FrameworkConfig via PPLSqlExtensionFunctions operator table. The PPL path resolves functions via PPLFuncImpTable and does not read the FrameworkConfig operator table, so there is no duplicate registration. Extract function list into PPLSqlExtensionFunctions for maintainability and to keep UnifiedQueryContext focused on configuration. The class is structured to accommodate future PPL extension functions for the SQL path. Tests cover all 7 functions across both PPL and SQL planning paths with exact logical plan assertions. Signed-off-by: Chen Dai <daichen@amazon.com>
1 parent c26a10f commit 8e54956

3 files changed

Lines changed: 252 additions & 0 deletions

File tree

api/src/main/java/org/opensearch/sql/api/UnifiedQueryContext.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,13 @@
2323
import org.apache.calcite.rel.metadata.DefaultRelMetadataProvider;
2424
import org.apache.calcite.schema.Schema;
2525
import org.apache.calcite.schema.SchemaPlus;
26+
import org.apache.calcite.sql.fun.SqlStdOperatorTable;
2627
import org.apache.calcite.sql.parser.SqlParser;
28+
import org.apache.calcite.sql.util.SqlOperatorTables;
2729
import org.apache.calcite.tools.FrameworkConfig;
2830
import org.apache.calcite.tools.Frameworks;
2931
import org.apache.calcite.tools.Programs;
32+
import org.opensearch.sql.api.function.PPLSqlExtensionFunctions;
3033
import org.opensearch.sql.api.parser.CalciteSqlQueryParser;
3134
import org.opensearch.sql.api.parser.PPLQueryParser;
3235
import org.opensearch.sql.api.parser.UnifiedQueryParser;
@@ -243,6 +246,9 @@ private FrameworkConfig buildFrameworkConfig() {
243246
SchemaPlus defaultSchema = findSchemaByPath(rootSchema, defaultNamespace);
244247
return Frameworks.newConfigBuilder()
245248
.parserConfig(buildParserConfig())
249+
.operatorTable(
250+
SqlOperatorTables.chain(
251+
SqlStdOperatorTable.instance(), PPLSqlExtensionFunctions.OPERATOR_TABLE))
246252
.defaultSchema(defaultSchema)
247253
.traitDefs((List<RelTraitDef>) null)
248254
.programs(Programs.calc(DefaultRelMetadataProvider.INSTANCE))
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/*
2+
* Copyright OpenSearch Contributors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package org.opensearch.sql.api.function;
7+
8+
import org.apache.calcite.sql.SqlOperator;
9+
import org.apache.calcite.sql.SqlOperatorTable;
10+
import org.apache.calcite.sql.util.SqlOperatorTables;
11+
import org.opensearch.sql.expression.function.PPLBuiltinOperators;
12+
13+
/**
14+
* Operator table containing PPL extension functions for use in the Calcite SQL planner. The PPL
15+
* path resolves functions via {@link
16+
* org.opensearch.sql.expression.function.PPLFuncImpTable#INSTANCE} and does not read the {@link
17+
* org.apache.calcite.tools.FrameworkConfig} operator table. The SQL path requires functions
18+
* registered here to be available during SQL validation and planning.
19+
*
20+
* <p>Standard SQL functions (UPPER, YEAR, ABS, etc.) are already available via {@link
21+
* org.apache.calcite.sql.fun.SqlStdOperatorTable}. Only PPL-specific functions that have no
22+
* standard equivalent need to be registered here.
23+
*/
24+
public class PPLSqlExtensionFunctions {
25+
26+
private PPLSqlExtensionFunctions() {}
27+
28+
/** All PPL extension functions available to the SQL planner. */
29+
public static final SqlOperatorTable OPERATOR_TABLE = buildOperatorTable();
30+
31+
private static SqlOperatorTable buildOperatorTable() {
32+
return SqlOperatorTables.of(relevanceSearchFunctions());
33+
}
34+
35+
/** Relevance search functions: match, match_phrase, multi_match, etc. */
36+
private static SqlOperator[] relevanceSearchFunctions() {
37+
return new SqlOperator[] {
38+
PPLBuiltinOperators.MATCH,
39+
PPLBuiltinOperators.MATCH_PHRASE,
40+
PPLBuiltinOperators.MATCH_BOOL_PREFIX,
41+
PPLBuiltinOperators.MATCH_PHRASE_PREFIX,
42+
PPLBuiltinOperators.SIMPLE_QUERY_STRING,
43+
PPLBuiltinOperators.QUERY_STRING,
44+
PPLBuiltinOperators.MULTI_MATCH
45+
};
46+
}
47+
}
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
/*
2+
* Copyright OpenSearch Contributors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package org.opensearch.sql.api;
7+
8+
import org.junit.Test;
9+
import org.opensearch.sql.executor.QueryType;
10+
11+
/** Tests for relevance search functions in both PPL and SQL planning paths. */
12+
public class UnifiedRelevanceSearchTest extends UnifiedQueryTestBase {
13+
14+
// ---- PPL path (default queryType is PPL from base class) ----
15+
16+
@Test
17+
public void testPplMatch() {
18+
givenQuery("source=catalog.employees | where match(name, 'John')")
19+
.assertPlan(
20+
"""
21+
LogicalFilter(condition=[match(MAP('field', $1), MAP('query', 'John':VARCHAR))])
22+
LogicalTableScan(table=[[catalog, employees]])
23+
""");
24+
}
25+
26+
@Test
27+
public void testPplMatchPhrase() {
28+
givenQuery("source=catalog.employees | where match_phrase(name, 'John Doe')")
29+
.assertPlan(
30+
"""
31+
LogicalFilter(condition=[match_phrase(MAP('field', $1), MAP('query', 'John Doe':VARCHAR))])
32+
LogicalTableScan(table=[[catalog, employees]])
33+
""");
34+
}
35+
36+
@Test
37+
public void testPplMatchBoolPrefix() {
38+
givenQuery("source=catalog.employees | where match_bool_prefix(name, 'John')")
39+
.assertPlan(
40+
"""
41+
LogicalFilter(condition=[match_bool_prefix(MAP('field', $1), MAP('query', 'John':VARCHAR))])
42+
LogicalTableScan(table=[[catalog, employees]])
43+
""");
44+
}
45+
46+
@Test
47+
public void testPplMatchPhrasePrefix() {
48+
givenQuery("source=catalog.employees | where match_phrase_prefix(name, 'John')")
49+
.assertPlan(
50+
"""
51+
LogicalFilter(condition=[match_phrase_prefix(MAP('field', $1), MAP('query', 'John':VARCHAR))])
52+
LogicalTableScan(table=[[catalog, employees]])
53+
""");
54+
}
55+
56+
@Test
57+
public void testPplMultiMatch() {
58+
givenQuery("source=catalog.employees | where multi_match(['name', 'department'], 'John')")
59+
.assertFields("id", "name", "age", "department");
60+
}
61+
62+
@Test
63+
public void testPplSimpleQueryString() {
64+
givenQuery("source=catalog.employees | where simple_query_string(['name'], 'John')")
65+
.assertFields("id", "name", "age", "department");
66+
}
67+
68+
@Test
69+
public void testPplQueryString() {
70+
givenQuery("source=catalog.employees | where query_string(['name'], 'John')")
71+
.assertFields("id", "name", "age", "department");
72+
}
73+
74+
// ---- SQL path ----
75+
76+
@Test
77+
public void testSqlMatch() {
78+
// 'match' is a reserved SQL keyword (MATCH_RECOGNIZE), so it must be quoted
79+
givenSqlQuery(
80+
"""
81+
SELECT *
82+
FROM catalog.employees
83+
WHERE "match"(MAP['field', name], MAP['query', 'John'])\
84+
""")
85+
.assertPlan(
86+
"""
87+
LogicalProject(id=[$0], name=[$1], age=[$2], department=[$3])
88+
LogicalFilter(condition=[match(MAP('field', $1), MAP('query', 'John'))])
89+
LogicalTableScan(table=[[catalog, employees]])
90+
""");
91+
}
92+
93+
@Test
94+
public void testSqlMatchPhrase() {
95+
givenSqlQuery(
96+
"""
97+
SELECT *
98+
FROM catalog.employees
99+
WHERE match_phrase(MAP['field', name], MAP['query', 'John Doe'])\
100+
""")
101+
.assertPlan(
102+
"""
103+
LogicalProject(id=[$0], name=[$1], age=[$2], department=[$3])
104+
LogicalFilter(condition=[match_phrase(MAP('field', $1), MAP('query', 'John Doe'))])
105+
LogicalTableScan(table=[[catalog, employees]])
106+
""");
107+
}
108+
109+
@Test
110+
public void testSqlMatchBoolPrefix() {
111+
givenSqlQuery(
112+
"""
113+
SELECT *
114+
FROM catalog.employees
115+
WHERE match_bool_prefix(MAP['field', name], MAP['query', 'John'])\
116+
""")
117+
.assertPlan(
118+
"""
119+
LogicalProject(id=[$0], name=[$1], age=[$2], department=[$3])
120+
LogicalFilter(condition=[match_bool_prefix(MAP('field', $1), MAP('query', 'John'))])
121+
LogicalTableScan(table=[[catalog, employees]])
122+
""");
123+
}
124+
125+
@Test
126+
public void testSqlMatchPhrasePrefix() {
127+
givenSqlQuery(
128+
"""
129+
SELECT *
130+
FROM catalog.employees
131+
WHERE match_phrase_prefix(MAP['field', name], MAP['query', 'John'])\
132+
""")
133+
.assertPlan(
134+
"""
135+
LogicalProject(id=[$0], name=[$1], age=[$2], department=[$3])
136+
LogicalFilter(condition=[match_phrase_prefix(MAP('field', $1), MAP('query', 'John'))])
137+
LogicalTableScan(table=[[catalog, employees]])
138+
""");
139+
}
140+
141+
@Test
142+
public void testSqlMatchWithOptionalParams() {
143+
givenSqlQuery(
144+
"""
145+
SELECT *
146+
FROM catalog.employees
147+
WHERE "match"(MAP['field', name], MAP['query', 'John'], MAP['boost', '1.5'])\
148+
""")
149+
.assertPlan(
150+
"""
151+
LogicalProject(id=[$0], name=[$1], age=[$2], department=[$3])
152+
LogicalFilter(condition=[match(MAP('field', $1), MAP('query', 'John'), MAP('boost', '1.5'))])
153+
LogicalTableScan(table=[[catalog, employees]])
154+
""");
155+
}
156+
157+
@Test
158+
public void testSqlMultiMatch() {
159+
givenSqlQuery(
160+
"""
161+
SELECT *
162+
FROM catalog.employees
163+
WHERE multi_match(\
164+
MAP['fields', MAP['name', 1.0, 'department', 2.0]], MAP['query', 'John'])\
165+
""")
166+
.assertFields("id", "name", "age", "department");
167+
}
168+
169+
@Test
170+
public void testSqlSimpleQueryString() {
171+
givenSqlQuery(
172+
"""
173+
SELECT *
174+
FROM catalog.employees
175+
WHERE simple_query_string(\
176+
MAP['fields', MAP['name', 1.0]], MAP['query', 'John'])\
177+
""")
178+
.assertFields("id", "name", "age", "department");
179+
}
180+
181+
@Test
182+
public void testSqlQueryString() {
183+
givenSqlQuery(
184+
"""
185+
SELECT *
186+
FROM catalog.employees
187+
WHERE query_string(\
188+
MAP['fields', MAP['name', 1.0]], MAP['query', 'John'])\
189+
""")
190+
.assertFields("id", "name", "age", "department");
191+
}
192+
193+
/** Helper to plan a SQL query using a separate SQL-configured planner. */
194+
private QueryAssert givenSqlQuery(String sql) {
195+
UnifiedQueryContext sqlContext = buildContext(QueryType.SQL);
196+
UnifiedQueryPlanner sqlPlanner = new UnifiedQueryPlanner(sqlContext);
197+
return new QueryAssert(sqlPlanner.plan(sql));
198+
}
199+
}

0 commit comments

Comments
 (0)