From d6335d2eedf88b6a93fe55797fd4e9f0e8282638 Mon Sep 17 00:00:00 2001 From: Chen Dai Date: Mon, 23 Mar 2026 15:02:56 -0700 Subject: [PATCH 1/5] feat(api): Add Calcite native SQL planning path in UnifiedQueryPlanner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add SQL support to the unified query API using Calcite's native parser pipeline (SqlParser → SqlValidator → SqlToRelConverter → RelNode), bypassing the ANTLR parser used by PPL. Changes: - UnifiedQueryPlanner: use PlanningStrategy to dispatch CalciteNativeStrategy vs CustomVisitorStrategy - CalciteNativeStrategy: Calcite Planner with try-with-resources for ANSI SQL - CustomVisitorStrategy: ANTLR-based path for PPL (and future SQL V2) - UnifiedQueryContext: SqlParser.Config with Casing.UNCHANGED to preserve lowercase OpenSearch index names Signed-off-by: Chen Dai --- .../sql/api/UnifiedQueryContext.java | 8 +- .../sql/api/UnifiedQueryPlanner.java | 101 +++++++++++------- 2 files changed, 70 insertions(+), 39 deletions(-) diff --git a/api/src/main/java/org/opensearch/sql/api/UnifiedQueryContext.java b/api/src/main/java/org/opensearch/sql/api/UnifiedQueryContext.java index 3e0a1f972bd..026dac86e2a 100644 --- a/api/src/main/java/org/opensearch/sql/api/UnifiedQueryContext.java +++ b/api/src/main/java/org/opensearch/sql/api/UnifiedQueryContext.java @@ -14,6 +14,7 @@ import java.util.Map; import java.util.Objects; import lombok.Value; +import org.apache.calcite.avatica.util.Casing; import org.apache.calcite.jdbc.CalciteSchema; import org.apache.calcite.plan.RelTraitDef; import org.apache.calcite.rel.metadata.DefaultRelMetadataProvider; @@ -176,13 +177,18 @@ private FrameworkConfig buildFrameworkConfig() { SchemaPlus defaultSchema = findSchemaByPath(rootSchema, defaultNamespace); return Frameworks.newConfigBuilder() - .parserConfig(SqlParser.Config.DEFAULT) + .parserConfig(buildParserConfig()) .defaultSchema(defaultSchema) .traitDefs((List) null) .programs(Programs.calc(DefaultRelMetadataProvider.INSTANCE)) .build(); } + private SqlParser.Config buildParserConfig() { + // Preserve identifier case for lowercase OpenSearch index names + return SqlParser.Config.DEFAULT.withUnquotedCasing(Casing.UNCHANGED); + } + private SchemaPlus findSchemaByPath(SchemaPlus rootSchema, String defaultPath) { if (defaultPath == null) { return rootSchema; diff --git a/api/src/main/java/org/opensearch/sql/api/UnifiedQueryPlanner.java b/api/src/main/java/org/opensearch/sql/api/UnifiedQueryPlanner.java index 91e35335e20..e6fcb0f5b16 100644 --- a/api/src/main/java/org/opensearch/sql/api/UnifiedQueryPlanner.java +++ b/api/src/main/java/org/opensearch/sql/api/UnifiedQueryPlanner.java @@ -5,17 +5,21 @@ package org.opensearch.sql.api; +import lombok.RequiredArgsConstructor; import org.antlr.v4.runtime.tree.ParseTree; import org.apache.calcite.rel.RelCollation; import org.apache.calcite.rel.RelCollations; import org.apache.calcite.rel.RelNode; +import org.apache.calcite.rel.RelRoot; import org.apache.calcite.rel.core.Sort; import org.apache.calcite.rel.logical.LogicalSort; +import org.apache.calcite.sql.SqlNode; +import org.apache.calcite.tools.Frameworks; +import org.apache.calcite.tools.Planner; import org.opensearch.sql.ast.statement.Query; import org.opensearch.sql.ast.statement.Statement; import org.opensearch.sql.ast.tree.UnresolvedPlan; import org.opensearch.sql.calcite.CalciteRelNodeVisitor; -import org.opensearch.sql.common.antlr.Parser; import org.opensearch.sql.common.antlr.SyntaxCheckException; import org.opensearch.sql.executor.QueryType; import org.opensearch.sql.ppl.antlr.PPLSyntaxParser; @@ -28,15 +32,9 @@ * such as Spark or command-line tools, abstracting away Calcite internals. */ public class UnifiedQueryPlanner { - /** The parser instance responsible for converting query text into a parse tree. */ - private final Parser parser; - /** Unified query context containing CalcitePlanContext with all configuration. */ - private final UnifiedQueryContext context; - - /** AST-to-RelNode visitor that builds logical plans from the parsed AST. */ - private final CalciteRelNodeVisitor relNodeVisitor = - new CalciteRelNodeVisitor(new EmptyDataSourceService()); + /** Planning strategy selected at construction time based on query type. */ + private final PlanningStrategy strategy; /** * Constructs a UnifiedQueryPlanner with a unified query context. @@ -44,20 +42,22 @@ public class UnifiedQueryPlanner { * @param context the unified query context containing CalcitePlanContext */ public UnifiedQueryPlanner(UnifiedQueryContext context) { - this.parser = buildQueryParser(context.getPlanContext().queryType); - this.context = context; + this.strategy = + context.getPlanContext().queryType == QueryType.SQL + ? new CalciteNativeStrategy(context) + : new CustomVisitorStrategy(context); } /** * Parses and analyzes a query string into a Calcite logical plan (RelNode). TODO: Generate * optimal physical plan to fully unify query execution and leverage Calcite's optimizer. * - * @param query the raw query string in PPL or other supported syntax + * @param query the raw query string in PPL or SQL syntax * @return a logical plan representing the query */ public RelNode plan(String query) { try { - return preserveCollation(analyze(parse(query))); + return strategy.plan(query); } catch (SyntaxCheckException e) { // Re-throw syntax error without wrapping throw e; @@ -66,38 +66,63 @@ public RelNode plan(String query) { } } - private Parser buildQueryParser(QueryType queryType) { - if (queryType == QueryType.PPL) { - return new PPLSyntaxParser(); - } - throw new IllegalArgumentException("Unsupported query type: " + queryType); + /** Strategy interface for language-specific planning logic. */ + private interface PlanningStrategy { + RelNode plan(String query) throws Exception; } - private UnresolvedPlan parse(String query) { - ParseTree cst = parser.parse(query); - AstStatementBuilder astStmtBuilder = - new AstStatementBuilder( - new AstBuilder(query, context.getSettings()), - AstStatementBuilder.StatementBuilderContext.builder().build()); - Statement statement = cst.accept(astStmtBuilder); + /** ANSI SQL planning using Calcite's native SqlParser → SqlValidator → SqlToRelConverter. */ + @RequiredArgsConstructor + private static class CalciteNativeStrategy implements PlanningStrategy { + private final UnifiedQueryContext context; - if (statement instanceof Query) { - return ((Query) statement).getPlan(); + @Override + public RelNode plan(String query) throws Exception { + try (Planner planner = Frameworks.getPlanner(context.getPlanContext().config)) { + SqlNode parsed = planner.parse(query); + SqlNode validated = planner.validate(parsed); + RelRoot relRoot = planner.rel(validated); + return relRoot.project(); + } } - throw new UnsupportedOperationException( - "Only query statements are supported but got " + statement.getClass().getSimpleName()); } - private RelNode analyze(UnresolvedPlan ast) { - return relNodeVisitor.analyze(ast, context.getPlanContext()); - } + /** AST-based planning via ANTLR parser → UnresolvedPlan → CalciteRelNodeVisitor. */ + @RequiredArgsConstructor + private static class CustomVisitorStrategy implements PlanningStrategy { + private final UnifiedQueryContext context; + private final PPLSyntaxParser parser = new PPLSyntaxParser(); + private final CalciteRelNodeVisitor relNodeVisitor = + new CalciteRelNodeVisitor(new EmptyDataSourceService()); + + @Override + public RelNode plan(String query) { + UnresolvedPlan ast = parse(query); + RelNode logical = relNodeVisitor.analyze(ast, context.getPlanContext()); + return preserveCollation(logical); + } + + private UnresolvedPlan parse(String query) { + ParseTree cst = parser.parse(query); + AstStatementBuilder astStmtBuilder = + new AstStatementBuilder( + new AstBuilder(query, context.getSettings()), + AstStatementBuilder.StatementBuilderContext.builder().build()); + Statement statement = cst.accept(astStmtBuilder); + + if (statement instanceof Query) { + return ((Query) statement).getPlan(); + } + throw new UnsupportedOperationException( + "Only query statements are supported but got " + statement.getClass().getSimpleName()); + } - private RelNode preserveCollation(RelNode logical) { - RelNode calcitePlan = logical; - RelCollation collation = logical.getTraitSet().getCollation(); - if (!(logical instanceof Sort) && collation != RelCollations.EMPTY) { - calcitePlan = LogicalSort.create(logical, collation, null, null); + private RelNode preserveCollation(RelNode logical) { + RelCollation collation = logical.getTraitSet().getCollation(); + if (!(logical instanceof Sort) && collation != RelCollations.EMPTY) { + return LogicalSort.create(logical, collation, null, null); + } + return logical; } - return calcitePlan; } } From 7d8613d684f3a37fd964089f606cbd2a8d74d32d Mon Sep 17 00:00:00 2001 From: Chen Dai Date: Mon, 23 Mar 2026 15:03:03 -0700 Subject: [PATCH 2/5] test(api): Add SQL planner tests and refactor test base for multi-language support - Refactor UnifiedQueryTestBase with queryType() hook for subclass override - Add UnifiedSqlQueryPlannerTest covering SELECT, WHERE, GROUP BY, JOIN, ORDER BY, subquery, case sensitivity, namespaces, and error handling - Update UnifiedQueryContextTest to verify SQL context creation Signed-off-by: Chen Dai --- .../sql/api/UnifiedQueryContextTest.java | 9 +- .../sql/api/UnifiedQueryPlannerSqlTest.java | 214 ++++++++++++++++++ .../sql/api/UnifiedQueryTestBase.java | 55 ++++- 3 files changed, 269 insertions(+), 9 deletions(-) create mode 100644 api/src/test/java/org/opensearch/sql/api/UnifiedQueryPlannerSqlTest.java diff --git a/api/src/test/java/org/opensearch/sql/api/UnifiedQueryContextTest.java b/api/src/test/java/org/opensearch/sql/api/UnifiedQueryContextTest.java index a3ad73f700a..ad2eba0fea5 100644 --- a/api/src/test/java/org/opensearch/sql/api/UnifiedQueryContextTest.java +++ b/api/src/test/java/org/opensearch/sql/api/UnifiedQueryContextTest.java @@ -63,14 +63,15 @@ public void testMissingQueryType() { UnifiedQueryContext.builder().catalog("opensearch", testSchema).build(); } - @Test(expected = IllegalArgumentException.class) - public void testUnsupportedQueryType() { + @Test + public void testSqlQueryType() { UnifiedQueryContext context = UnifiedQueryContext.builder() - .language(QueryType.SQL) // only PPL is supported for now + .language(QueryType.SQL) .catalog("opensearch", testSchema) .build(); - new UnifiedQueryPlanner(context); + UnifiedQueryPlanner planner = new UnifiedQueryPlanner(context); + assertNotNull("SQL planner should be created", planner); } @Test(expected = IllegalArgumentException.class) diff --git a/api/src/test/java/org/opensearch/sql/api/UnifiedQueryPlannerSqlTest.java b/api/src/test/java/org/opensearch/sql/api/UnifiedQueryPlannerSqlTest.java new file mode 100644 index 00000000000..53accd49715 --- /dev/null +++ b/api/src/test/java/org/opensearch/sql/api/UnifiedQueryPlannerSqlTest.java @@ -0,0 +1,214 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.sql.api; + +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThrows; + +import java.util.Map; +import org.apache.calcite.schema.Schema; +import org.apache.calcite.schema.impl.AbstractSchema; +import org.junit.Test; +import org.opensearch.sql.executor.QueryType; + +public class UnifiedQueryPlannerSqlTest extends UnifiedQueryTestBase { + + private final AbstractSchema testDeepSchema = + new AbstractSchema() { + @Override + protected Map getSubSchemaMap() { + return Map.of("opensearch", testSchema); + } + }; + + @Override + protected QueryType queryType() { + return QueryType.SQL; + } + + @Test + public void testSqlQueryPlanning() { + givenQuery( + """ + SELECT * + FROM catalog.employees\ + """) + .assertPlan( + """ + LogicalProject(id=[$0], name=[$1], age=[$2], department=[$3]) + LogicalTableScan(table=[[catalog, employees]]) + """); + } + + @Test + public void testSqlSelectSpecificColumns() { + givenQuery( + """ + SELECT id, name + FROM catalog.employees\ + """) + .assertPlan( + """ + LogicalProject(id=[$0], name=[$1]) + LogicalTableScan(table=[[catalog, employees]]) + """); + } + + @Test + public void testSqlFilterQueryPlanning() { + givenQuery( + """ + SELECT name + FROM catalog.employees + WHERE age > 30\ + """) + .assertPlan( + """ + LogicalProject(name=[$1]) + LogicalFilter(condition=[>($2, 30)]) + LogicalTableScan(table=[[catalog, employees]]) + """); + } + + @Test + public void testSqlAggregateQueryPlanning() { + givenQuery( + """ + SELECT department, count(*) AS cnt + FROM catalog.employees + GROUP BY department\ + """) + .assertPlan( + """ + LogicalAggregate(group=[{0}], cnt=[COUNT()]) + LogicalProject(department=[$3]) + LogicalTableScan(table=[[catalog, employees]]) + """); + } + + @Test + public void testSqlJoinQueryPlanning() { + givenQuery( + """ + SELECT a.id, b.name + FROM catalog.employees a + JOIN catalog.employees b ON a.id = b.age\ + """) + .assertPlan( + """ + LogicalProject(id=[$0], name=[$5]) + LogicalJoin(condition=[=($0, $6)], joinType=[inner]) + LogicalTableScan(table=[[catalog, employees]]) + LogicalTableScan(table=[[catalog, employees]]) + """); + } + + @Test + public void testSqlOrderByQueryPlanning() { + givenQuery( + """ + SELECT name + FROM catalog.employees + ORDER BY age DESC\ + """) + .assertPlan( + """ + LogicalProject(name=[$0]) + LogicalSort(sort0=[$1], dir0=[DESC]) + LogicalProject(name=[$1], age=[$2]) + LogicalTableScan(table=[[catalog, employees]]) + """); + } + + @Test + public void testSqlSubqueryPlanning() { + // Calcite represents scalar subqueries as $SCALAR_QUERY{...} with embedded plan text whose + // formatting (whitespace, line breaks) may vary across versions. Assert output fields only. + givenQuery( + """ + SELECT name + FROM catalog.employees + WHERE age > (SELECT avg(age) FROM catalog.employees)\ + """) + .assertFields("name"); + } + + @Test + public void testSqlCteQueryPlanning() { + // CTE is inlined by Calcite — same plan as a direct filter query + givenQuery( + """ + WITH seniors AS ( + SELECT name, age FROM catalog.employees WHERE age > 30 + ) + SELECT name + FROM seniors\ + """) + .assertPlan( + """ + LogicalProject(name=[$1]) + LogicalFilter(condition=[>($2, 30)]) + LogicalTableScan(table=[[catalog, employees]]) + """); + } + + @Test + public void testSqlQueryPlanningWithDefaultNamespace() { + UnifiedQueryContext sqlContext = + UnifiedQueryContext.builder() + .language(QueryType.SQL) + .catalog("opensearch", testSchema) + .defaultNamespace("opensearch") + .build(); + UnifiedQueryPlanner sqlPlanner = new UnifiedQueryPlanner(sqlContext); + + assertNotNull("Plan should be created", sqlPlanner.plan("SELECT * FROM opensearch.employees")); + assertNotNull("Plan should be created", sqlPlanner.plan("SELECT * FROM employees")); + } + + @Test + public void testSqlQueryPlanningWithDefaultNamespaceMultiLevel() { + UnifiedQueryContext sqlContext = + UnifiedQueryContext.builder() + .language(QueryType.SQL) + .catalog("catalog", testDeepSchema) + .defaultNamespace("catalog.opensearch") + .build(); + UnifiedQueryPlanner sqlPlanner = new UnifiedQueryPlanner(sqlContext); + + assertNotNull( + "Plan should be created", sqlPlanner.plan("SELECT * FROM catalog.opensearch.employees")); + assertNotNull("Plan should be created", sqlPlanner.plan("SELECT * FROM employees")); + + assertThrows( + IllegalStateException.class, () -> sqlPlanner.plan("SELECT * FROM opensearch.employees")); + } + + @Test + public void testSqlQueryPlanningWithMultipleCatalogs() { + UnifiedQueryContext sqlContext = + UnifiedQueryContext.builder() + .language(QueryType.SQL) + .catalog("catalog1", testSchema) + .catalog("catalog2", testSchema) + .build(); + UnifiedQueryPlanner sqlPlanner = new UnifiedQueryPlanner(sqlContext); + + assertNotNull( + "Plan should be created", + sqlPlanner.plan( + """ + SELECT a.id + FROM catalog1.employees a + JOIN catalog2.employees b ON a.id = b.id\ + """)); + } + + @Test + public void testInvalidSqlThrowsException() { + assertThrows(IllegalStateException.class, () -> planner.plan("SELECT FROM")); + } +} diff --git a/api/src/testFixtures/java/org/opensearch/sql/api/UnifiedQueryTestBase.java b/api/src/testFixtures/java/org/opensearch/sql/api/UnifiedQueryTestBase.java index 000b145695a..62adecf5d3b 100644 --- a/api/src/testFixtures/java/org/opensearch/sql/api/UnifiedQueryTestBase.java +++ b/api/src/testFixtures/java/org/opensearch/sql/api/UnifiedQueryTestBase.java @@ -7,6 +7,7 @@ import static org.apache.calcite.sql.type.SqlTypeName.INTEGER; import static org.apache.calcite.sql.type.SqlTypeName.VARCHAR; +import static org.junit.Assert.assertEquals; import java.util.List; import java.util.Map; @@ -15,6 +16,8 @@ import org.apache.calcite.DataContext; import org.apache.calcite.linq4j.Enumerable; import org.apache.calcite.linq4j.Linq4j; +import org.apache.calcite.plan.RelOptUtil; +import org.apache.calcite.rel.RelNode; import org.apache.calcite.rel.type.RelDataType; import org.apache.calcite.rel.type.RelDataTypeFactory; import org.apache.calcite.schema.ScannableTable; @@ -55,14 +58,25 @@ protected Map getTableMap() { } }; - context = - UnifiedQueryContext.builder() - .language(QueryType.PPL) - .catalog(DEFAULT_CATALOG, testSchema) - .build(); + context = buildContext(queryType()); planner = new UnifiedQueryPlanner(context); } + /** + * Returns the query type for this test class. Subclasses override to test different languages. + */ + protected QueryType queryType() { + return QueryType.PPL; + } + + /** Builds a UnifiedQueryContext with the test schema for the given query type. */ + protected UnifiedQueryContext buildContext(QueryType queryType) { + return UnifiedQueryContext.builder() + .language(queryType) + .catalog(DEFAULT_CATALOG, testSchema) + .build(); + } + @After public void tearDown() throws Exception { if (context != null) { @@ -128,4 +142,35 @@ public boolean rolledUpColumnValidInsideAgg( return false; } } + + /** Fluent helper for asserting query plan results. */ + protected QueryAssert givenQuery(String query) { + return new QueryAssert(planner.plan(query)); + } + + /** Fluent assertion on a query's logical plan. */ + protected static class QueryAssert { + private final RelNode plan; + + QueryAssert(RelNode plan) { + this.plan = plan; + } + + /** Assert the logical plan matches the expected tree string. */ + public QueryAssert assertPlan(String expected) { + assertEquals(expected, RelOptUtil.toString(plan)); + return this; + } + + /** Assert the output field names match. */ + public QueryAssert assertFields(String... names) { + assertEquals(List.of(names), plan.getRowType().getFieldNames()); + return this; + } + + /** Access the underlying plan for custom assertions. */ + public RelNode plan() { + return plan; + } + } } From 9c03e0246e666fab118afb12eb70796bc93ba1ff Mon Sep 17 00:00:00 2001 From: Chen Dai Date: Mon, 23 Mar 2026 15:03:09 -0700 Subject: [PATCH 3/5] perf(benchmarks): Add SQL queries to UnifiedQueryBenchmark Add language (PPL/SQL) and queryPattern param dimensions for side-by-side comparison of equivalent queries across both languages. Remove separate UnifiedSqlQueryBenchmark in favor of unified class. Signed-off-by: Chen Dai --- .../sql/api/UnifiedQueryBenchmark.java | 75 +++++++++++++++---- 1 file changed, 61 insertions(+), 14 deletions(-) diff --git a/benchmarks/src/jmh/java/org/opensearch/sql/api/UnifiedQueryBenchmark.java b/benchmarks/src/jmh/java/org/opensearch/sql/api/UnifiedQueryBenchmark.java index d75a87ea8c3..aeb47e78821 100644 --- a/benchmarks/src/jmh/java/org/opensearch/sql/api/UnifiedQueryBenchmark.java +++ b/benchmarks/src/jmh/java/org/opensearch/sql/api/UnifiedQueryBenchmark.java @@ -6,6 +6,7 @@ package org.opensearch.sql.api; import java.sql.PreparedStatement; +import java.util.Map; import java.util.concurrent.TimeUnit; import org.apache.calcite.rel.RelNode; import org.apache.calcite.sql.dialect.SparkSqlDialect; @@ -24,10 +25,12 @@ import org.openjdk.jmh.annotations.Warmup; import org.opensearch.sql.api.compiler.UnifiedQueryCompiler; import org.opensearch.sql.api.transpiler.UnifiedQueryTranspiler; +import org.opensearch.sql.executor.QueryType; /** - * JMH benchmark for measuring the overhead of unified query API components when processing queries. - * This provides baseline metrics and guidance for API consumers during integration. + * JMH benchmark for measuring the overhead of unified query API components when processing PPL and + * SQL queries. The {@code language} and {@code queryPattern} parameters produce a cross-product, + * enabling side-by-side comparison of equivalent queries across both languages. */ @Warmup(iterations = 2, time = 1) @Measurement(iterations = 5, time = 1) @@ -37,25 +40,69 @@ @Fork(value = 1) public class UnifiedQueryBenchmark extends UnifiedQueryTestBase { - /** Common query patterns for benchmarking. */ - @Param({ - "source = catalog.employees", - "source = catalog.employees | where age > 30", - "source = catalog.employees | stats count() by department", - "source = catalog.employees | sort - age", - "source = catalog.employees | where age > 25 | stats avg(age) by department | sort - department" - }) - private String query; + private static final Map PPL_QUERIES = + Map.of( + "scan", "source = catalog.employees", + "filter", "source = catalog.employees | where age > 30", + "aggregate", "source = catalog.employees | stats count() by department", + "sort", "source = catalog.employees | sort - age", + "complex", + """ + source = catalog.employees \ + | where age > 25 \ + | stats avg(age) by department \ + | sort - department\ + """); - /** Transpiler for converting logical plans to SQL strings. */ - private UnifiedQueryTranspiler transpiler; + private static final Map SQL_QUERIES = + Map.of( + "scan", "SELECT * FROM catalog.employees", + "filter", + """ + SELECT * + FROM catalog.employees + WHERE age > 30\ + """, + "aggregate", + """ + SELECT department, count(*) + FROM catalog.employees + GROUP BY department\ + """, + "sort", + """ + SELECT * + FROM catalog.employees + ORDER BY age DESC\ + """, + "complex", + """ + SELECT department, avg(age) + FROM catalog.employees + WHERE age > 25 + GROUP BY department + ORDER BY department\ + """); + + @Param({"PPL", "SQL"}) + private String language; + + @Param({"scan", "filter", "aggregate", "sort", "complex"}) + private String queryPattern; - /** Compiler for converting logical plans to executable statements. */ + private String query; + private UnifiedQueryTranspiler transpiler; private UnifiedQueryCompiler compiler; + @Override + protected QueryType queryType() { + return QueryType.valueOf(language); + } + @Setup(Level.Trial) public void setUpBenchmark() { super.setUp(); + query = (language.equals("PPL") ? PPL_QUERIES : SQL_QUERIES).get(queryPattern); transpiler = UnifiedQueryTranspiler.builder().dialect(SparkSqlDialect.DEFAULT).build(); compiler = new UnifiedQueryCompiler(context); } From e40e02a30e1bf98ca345f69d7641f96ecb064e2e Mon Sep 17 00:00:00 2001 From: Chen Dai Date: Mon, 23 Mar 2026 15:12:51 -0700 Subject: [PATCH 4/5] docs(api): Update README to reflect SQL support in UnifiedQueryPlanner Signed-off-by: Chen Dai --- api/README.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/api/README.md b/api/README.md index 91651aa3153..76c64167658 100644 --- a/api/README.md +++ b/api/README.md @@ -8,7 +8,7 @@ This module provides components organized into two main areas aligned with the [ ### Unified Language Specification -- **`UnifiedQueryPlanner`**: Accepts PPL (Piped Processing Language) queries and returns Calcite `RelNode` logical plans as intermediate representation. +- **`UnifiedQueryPlanner`**: Accepts PPL (Piped Processing Language) or SQL queries and returns Calcite `RelNode` logical plans as intermediate representation. - **`UnifiedQueryTranspiler`**: Converts Calcite logical plans (`RelNode`) into SQL strings for various target databases using different SQL dialects. ### Unified Execution Runtime @@ -17,7 +17,7 @@ This module provides components organized into two main areas aligned with the [ - **`UnifiedFunction`**: Engine-agnostic function interface that enables functions to be evaluated across different execution engines without engine-specific code duplication. - **`UnifiedFunctionRepository`**: Repository for discovering and loading functions as `UnifiedFunction` instances, providing a bridge between function definitions and external execution engines. -Together, these components enable complete workflows: parse PPL queries into logical plans, transpile those plans into target database SQL, compile and execute queries directly, or export PPL functions for use in external execution engines. +Together, these components enable complete workflows: parse PPL or SQL queries into logical plans, transpile those plans into target database SQL, compile and execute queries directly, or export PPL functions for use in external execution engines. ### Experimental API Design @@ -33,7 +33,7 @@ Create a context with catalog configuration, query type, and optional settings: ```java UnifiedQueryContext context = UnifiedQueryContext.builder() - .language(QueryType.PPL) + .language(QueryType.PPL) // or QueryType.SQL for SQL .catalog("opensearch", opensearchSchema) .catalog("spark_catalog", sparkSchema) .defaultNamespace("opensearch") @@ -44,7 +44,7 @@ UnifiedQueryContext context = UnifiedQueryContext.builder() ### UnifiedQueryPlanner -Use `UnifiedQueryPlanner` to parse and analyze PPL queries into Calcite logical plans. The planner accepts a `UnifiedQueryContext` and can be reused for multiple queries. +Use `UnifiedQueryPlanner` to parse and analyze PPL or SQL queries into Calcite logical plans. The planner accepts a `UnifiedQueryContext` and can be reused for multiple queries. ```java // Create planner with context @@ -53,6 +53,9 @@ UnifiedQueryPlanner planner = new UnifiedQueryPlanner(context); // Plan multiple queries (context is reused) RelNode plan1 = planner.plan("source = logs | where status = 200"); RelNode plan2 = planner.plan("source = metrics | stats avg(cpu)"); + +// SQL queries are also supported (with QueryType.SQL context) +RelNode plan3 = planner.plan("SELECT * FROM logs WHERE status = 200"); ``` ### UnifiedQueryTranspiler @@ -226,5 +229,4 @@ public class MySchema extends AbstractSchema { ## Future Work -- Expand support to SQL language. - Extend planner to generate optimized physical plans using Calcite's optimization frameworks. From 5880ce14e72baeb3c3cbd668fee6752ecf366c4f Mon Sep 17 00:00:00 2001 From: Chen Dai Date: Wed, 25 Mar 2026 14:52:49 -0700 Subject: [PATCH 5/5] fix(api): Normalize trailing whitespace in assertPlan comparison RelOptUtil.toString() appends a trailing newline after the last plan node, which doesn't match Java text block expectations. Also add \r\n normalization for Windows CI compatibility, consistent with the existing pattern in core module tests. Signed-off-by: Chen Dai --- .../java/org/opensearch/sql/api/UnifiedQueryTestBase.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/api/src/testFixtures/java/org/opensearch/sql/api/UnifiedQueryTestBase.java b/api/src/testFixtures/java/org/opensearch/sql/api/UnifiedQueryTestBase.java index 62adecf5d3b..eafbfbbeeda 100644 --- a/api/src/testFixtures/java/org/opensearch/sql/api/UnifiedQueryTestBase.java +++ b/api/src/testFixtures/java/org/opensearch/sql/api/UnifiedQueryTestBase.java @@ -158,7 +158,9 @@ protected static class QueryAssert { /** Assert the logical plan matches the expected tree string. */ public QueryAssert assertPlan(String expected) { - assertEquals(expected, RelOptUtil.toString(plan)); + assertEquals( + expected.stripTrailing(), + RelOptUtil.toString(plan).replaceAll("\\r\\n", "\n").stripTrailing()); return this; }