Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,11 @@ public static class Builder {
* Setting values with defaults from SysLimit.DEFAULT. Only includes planning-required settings
* to avoid coupling with OpenSearchSettings.
*
* <p>{@link Settings.Key#PPL_JOIN_SUBSEARCH_MAXOUT} defaults to {@code 0} to avoid injecting
* {@code LogicalSystemLimit} into the logical plan, which is an OpenSearch-specific operational
* concern irrelevant to external consumers of the unified query API. {@link
* Settings.Key#PPL_SUBSEARCH_MAXOUT} is set to {@code 0} for the same reason.
*
* <p>{@link Settings.Key#CALCITE_ENGINE_ENABLED} defaults to {@code true} here because the
* unified query path is by definition Calcite-based — every query reaching this context flows
* through Calcite's planner, never the v2 engine. The PPL {@link
Expand All @@ -142,8 +147,8 @@ public static class Builder {
new HashMap<Settings.Key, Object>(
Map.of(
QUERY_SIZE_LIMIT, SysLimit.DEFAULT.querySizeLimit(),
PPL_SUBSEARCH_MAXOUT, SysLimit.DEFAULT.subsearchLimit(),
PPL_JOIN_SUBSEARCH_MAXOUT, SysLimit.DEFAULT.joinSubsearchLimit(),
PPL_SUBSEARCH_MAXOUT, SysLimit.UNLIMITED_SUBSEARCH.subsearchLimit(),
PPL_JOIN_SUBSEARCH_MAXOUT, SysLimit.UNLIMITED_SUBSEARCH.joinSubsearchLimit(),
CALCITE_ENGINE_ENABLED, true,
PPL_REX_MAX_MATCH_LIMIT, 10));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,18 @@

package org.opensearch.sql.api.parser;

import static org.opensearch.sql.ast.dsl.AstDSL.join;

import java.util.Optional;
import org.antlr.v4.runtime.tree.ParseTree;
import org.opensearch.sql.ast.expression.UnresolvedExpression;
import org.opensearch.sql.ast.statement.Query;
import org.opensearch.sql.ast.statement.Statement;
import org.opensearch.sql.ast.tree.Join.JoinType;
import org.opensearch.sql.ast.tree.UnresolvedPlan;
import org.opensearch.sql.sql.antlr.SQLSyntaxParser;
import org.opensearch.sql.sql.antlr.parser.OpenSearchSQLParser;
import org.opensearch.sql.sql.antlr.parser.OpenSearchSQLParser.JoinClauseContext;
import org.opensearch.sql.sql.parser.AstBuilder;
import org.opensearch.sql.sql.parser.AstStatementBuilder;

Expand All @@ -24,7 +31,8 @@ public UnresolvedPlan parse(String query) {
ParseTree cst = syntaxParser.parse(query);
AstStatementBuilder astStmtBuilder =
new AstStatementBuilder(
new AstBuilder(query), AstStatementBuilder.StatementBuilderContext.builder().build());
new ExtendedAstBuilder(query),
AstStatementBuilder.StatementBuilderContext.builder().build());
Statement statement = cst.accept(astStmtBuilder);

if (statement instanceof Query) {
Expand All @@ -33,4 +41,33 @@ public UnresolvedPlan parse(String query) {
throw new UnsupportedOperationException(
"Only query statements are supported but got " + statement.getClass().getSimpleName());
}

/**
* Extends the V2 AstBuilder with JOIN support that the base AstBuilder rejects with
* SyntaxCheckException to trigger legacy engine fallback.
*/
private static class ExtendedAstBuilder extends AstBuilder {

ExtendedAstBuilder(String query) {
super(query);
}

@Override
public UnresolvedPlan visitJoinClause(JoinClauseContext ctx) {
JoinType joinType = toJoinType(ctx);
UnresolvedPlan right = visit(ctx.relation());
Optional<UnresolvedExpression> condition =
Optional.ofNullable(ctx.expression()).map(this::visitAstExpression);
return join(right, joinType, condition);
}

private JoinType toJoinType(JoinClauseContext ctx) {
return switch (ctx.getStart().getType()) {
case OpenSearchSQLParser.LEFT -> JoinType.LEFT;
case OpenSearchSQLParser.RIGHT -> JoinType.RIGHT;
case OpenSearchSQLParser.CROSS -> JoinType.CROSS;
default -> JoinType.INNER;
};
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ public void testContextCreationWithDefaults() {
assertNotNull("PlanContext should be created", context.getPlanContext());
assertNotNull("Settings should be created", context.getSettings());
assertEquals(
"Settings should have default system limits",
SysLimit.DEFAULT,
"Settings should have unlimited subsearch limits for clean logical plans",
new SysLimit(SysLimit.DEFAULT.querySizeLimit(), 0, 0),
SysLimit.fromSettings(context.getSettings()));
assertEquals(
"PPL_REX_MAX_MATCH_LIMIT default should be 10",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,19 @@

package org.opensearch.sql.api;

import static org.apache.calcite.sql.type.SqlTypeName.INTEGER;
import static org.apache.calcite.sql.type.SqlTypeName.VARCHAR;

import java.util.Map;
import org.apache.calcite.schema.Table;
import org.apache.calcite.schema.impl.AbstractSchema;
import org.junit.Before;
import org.junit.Test;
import org.opensearch.sql.executor.QueryType;

/**
* Tests for basic SQL query planning through the V2 ANTLR parser path. Covers SELECT, WHERE, ORDER
* BY operations that produce valid RelNode plans.
* Tests for SQL query planning through the V2 ANTLR parser path. Covers SELECT, WHERE, ORDER BY,
* and JOIN operations that produce valid RelNode plans.
*/
public class UnifiedQueryPlannerSqlV2Test extends UnifiedQueryTestBase {

Expand All @@ -19,17 +26,35 @@ protected QueryType queryType() {
return QueryType.SQL;
}

@Test
public void selectStar() {
givenQuery("SELECT * FROM catalog.employees")
.assertPlan(
"""
LogicalTableScan(table=[[catalog, employees]])
""");
@Before
@Override
public void setUp() {
testSchema =
new AbstractSchema() {
@Override
protected Map<String, Table> getTableMap() {
return Map.of(
"employees", createEmployeesTable(),
"departments", createDepartmentsTable());
}
};

context = contextBuilder().build();
planner = new UnifiedQueryPlanner(context);
}

private Table createDepartmentsTable() {
return SimpleTable.builder()
.col("dept_id", INTEGER)
.col("dept_name", VARCHAR)
.row(new Object[] {1, "Engineering"})
.row(new Object[] {2, "Sales"})
.row(new Object[] {3, "Marketing"})
.build();
}

@Test
public void selectStarFields() {
public void selectStar() {
givenQuery("SELECT * FROM catalog.employees")
.assertPlan(
"""
Expand Down Expand Up @@ -68,4 +93,53 @@ public void testFilterAndOrderBy() {
LogicalTableScan(table=[[catalog, employees]])
""");
}

@Test
public void testJoinTypes() {
Map.of("JOIN", "inner", "LEFT JOIN", "left", "RIGHT JOIN", "right")
.forEach(
(syntax, type) ->
givenQuery(
"""
SELECT * FROM catalog.employees %s catalog.departments
ON employees.department = departments.dept_name
"""
.formatted(syntax))
.assertPlan(
"""
LogicalJoin(condition=[=($3, $5)], joinType=[%s])
LogicalTableScan(table=[[catalog, employees]])
LogicalTableScan(table=[[catalog, departments]])
"""
.formatted(type)));
}

@Test
public void testCrossJoin() {
givenQuery("SELECT * FROM catalog.employees CROSS JOIN catalog.departments")
.assertPlan(
"""
LogicalJoin(condition=[true], joinType=[inner])
LogicalTableScan(table=[[catalog, employees]])
LogicalTableScan(table=[[catalog, departments]])
""");
}

@Test
public void testJoinWithFilterAndOrderBy() {
givenQuery(
"""
SELECT * FROM catalog.employees JOIN catalog.departments
ON employees.department = departments.dept_name
WHERE employees.age > 30 ORDER BY employees.name
""")
.assertPlan(
"""
LogicalSort(sort0=[$1], dir0=[ASC-nulls-first])
LogicalFilter(condition=[>($2, 30)])
LogicalJoin(condition=[=($3, $5)], joinType=[inner])
LogicalTableScan(table=[[catalog, employees]])
LogicalTableScan(table=[[catalog, departments]])
""");
}
}
14 changes: 14 additions & 0 deletions core/src/main/java/org/opensearch/sql/ast/dsl/AstDSL.java
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
import org.opensearch.sql.ast.tree.FillNull;
import org.opensearch.sql.ast.tree.Filter;
import org.opensearch.sql.ast.tree.Head;
import org.opensearch.sql.ast.tree.Join;
import org.opensearch.sql.ast.tree.Limit;
import org.opensearch.sql.ast.tree.MinSpanBin;
import org.opensearch.sql.ast.tree.MvCombine;
Expand Down Expand Up @@ -757,4 +758,17 @@ public static Bin bin(UnresolvedExpression field, Argument... arguments) {
public static Field implicitTimestampField() {
return AstDSL.field(OpenSearchConstants.IMPLICIT_FIELD_TIMESTAMP);
}

public static UnresolvedPlan join(
UnresolvedPlan right, Join.JoinType joinType, Optional<UnresolvedExpression> condition) {
return new Join(
right,
Optional.empty(),
Optional.empty(),
joinType,
condition,
new Join.JoinHint(),
Optional.empty(),
Argument.ArgumentMap.empty());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

package org.opensearch.sql.legacy;

import static org.opensearch.sql.legacy.TestsConstants.TEST_INDEX_DOG;
import static org.opensearch.sql.legacy.TestsConstants.TEST_INDEX_PEOPLE;
import static org.opensearch.sql.util.MatcherUtils.rows;
import static org.opensearch.sql.util.MatcherUtils.verifyDataRows;

import java.io.IOException;
import org.json.JSONObject;
import org.junit.Test;

/**
* Sanity tests for the legacy SQL engine. Many legacy integration tests (JoinIT, SubqueryIT,
* MultiQueryIT, etc.) are @Ignored because they assert on the deprecated JSON response format
* removed in 3.0. These tests provide minimal coverage that the legacy engine still executes
* correctly for queries that fall back from the V2 engine.
*/
public class SqlLegacyEngineSanityIT extends SQLIntegTestCase {

@Override
protected void init() throws Exception {
loadIndex(Index.DOG);
loadIndex(Index.PEOPLE);
}

@Test
public void testInnerJoinFallback() throws IOException {
JSONObject result =
executeQuery(
"SELECT a.firstname, d.dog_name FROM %s a JOIN %s d ON d.holdersName = a.firstname WHERE a.age > 35"
.formatted(TEST_INDEX_PEOPLE, TEST_INDEX_DOG));
verifyDataRows(result, rows("Hattie", "snoopy"));
}

@Test
public void testLeftJoinFallback() throws IOException {
JSONObject result =
executeQuery(
"SELECT a.firstname, d.dog_name FROM %s a LEFT JOIN %s d ON d.holdersName = a.firstname WHERE a.firstname = 'Daenerys'"
.formatted(TEST_INDEX_PEOPLE, TEST_INDEX_DOG));
verifyDataRows(result, rows("Daenerys", "rex"));
}
}
24 changes: 22 additions & 2 deletions sql/src/main/antlr/OpenSearchSQLParser.g4
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,20 @@ parser grammar OpenSearchSQLParser;


options { tokenVocab = OpenSearchSQLLexer; }

@members {
/**
* Returns true if the next token is a join keyword that should not be consumed as a table alias.
* LEFT/RIGHT are valid identifiers (text function names), so without this predicate ANTLR4
* greedily consumes them as implicit table aliases (e.g., FROM t1 LEFT becomes alias='LEFT'
* instead of starting a LEFT JOIN clause).
*/
private boolean isJoinKeyword() {
int t = _input.LT(1).getType();
return t == LEFT || t == RIGHT || t == INNER || t == CROSS || t == JOIN;
}
}

// Top Level Description

// Root rule
Expand Down Expand Up @@ -104,12 +118,18 @@ selectElement
;

fromClause
: FROM relation (whereClause)? (groupByClause)? (havingClause)? (orderByClause)? // Place it under FROM for now but actually not necessary ex. A UNION B ORDER BY
: FROM relation joinClause* (whereClause)? (groupByClause)? (havingClause)? (orderByClause)? // Place it under FROM for now but actually not necessary ex. A UNION B ORDER BY

;

joinClause
: (INNER | CROSS)? JOIN relation (ON expression)?
| (LEFT | RIGHT) OUTER? JOIN relation (ON expression)?
;

relation
: tableName (AS? alias)? # tableAsRelation
// The predicate guarantees only match implicit alias if next token is NOT a join keyword
: tableName (AS alias | {!isJoinKeyword()}? alias)? # tableAsRelation
| LR_BRACKET subquery = querySpecification RR_BRACKET AS? alias # subqueryAsRelation
| qualifiedName LR_BRACKET tableFunctionArgs RR_BRACKET (AS? alias)? # tableFunctionRelation
;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,10 @@ public UnresolvedPlan visitLimitClause(OpenSearchSQLParser.LimitClauseContext ct
public UnresolvedPlan visitFromClause(FromClauseContext ctx) {
UnresolvedPlan result = visit(ctx.relation());

for (var joinCtx : ctx.joinClause()) {
result = visit(joinCtx).attach(result);
}

if (ctx.whereClause() != null) {
result = visit(ctx.whereClause()).attach(result);
}
Expand Down Expand Up @@ -250,6 +254,12 @@ public UnresolvedPlan visitWhereClause(WhereClauseContext ctx) {
return new Filter(visitAstExpression(ctx.expression()));
}

@Override
public UnresolvedPlan visitJoinClause(OpenSearchSQLParser.JoinClauseContext ctx) {
throw new SyntaxCheckException(
"JOIN is not supported in the V2 SQL engine. Falling back to legacy engine.");
}

@Override
public UnresolvedPlan visitHavingClause(HavingClauseContext ctx) {
AstHavingFilterBuilder builder = new AstHavingFilterBuilder(context.peek());
Expand All @@ -261,7 +271,11 @@ protected UnresolvedPlan aggregateResult(UnresolvedPlan aggregate, UnresolvedPla
return nextResult != null ? nextResult : aggregate;
}

private UnresolvedExpression visitAstExpression(ParseTree tree) {
/**
* Visit expression tree node and convert to UnresolvedExpression. Protected to allow subclass
* access (e.g., ExtendedAstBuilder for join conditions).
*/
protected UnresolvedExpression visitAstExpression(ParseTree tree) {
return expressionBuilder.visit(tree);
}

Expand Down
Loading
Loading