Skip to content

Commit 19e6693

Browse files
author
Hanyu Wei
committed
feat(ppl): support bare-field join criteria join on <field> shorthand
Let `on`/`where` join criteria accept a bare field name (or `AND` of bare fields) as shorthand for the qualified equality on that common field: source=t1 | inner join on a t2 == on t1.a = t2.a source=t1 | inner join on a AND b t2 == on t1.a = t2.a AND t1.b = t2.b source=t1 | join on a t2 == on t1.a = t2.a (no prefix/alias) The shorthand behaves exactly like the explicit `on l.f = r.f` criteria: it KEEPS BOTH key columns (the right key renamed `<rightAlias>.f`, or `<rightTable>.f` when no alias is given). This differs from the field-list join (`join f t2`), which merges the duplicate key to a single column. Implementation: - Grammar: reorder the joinCommand alternatives so the criteria alternative is listed first. This lets the no-prefix form `join on a <right>` parse `on` as the criteria keyword instead of the field-list alternative greedily consuming `on`/`where` as a field name. ANTLR ALL(*) picks the first-listed alternative on genuine ambiguity. - AstBuilder: a bare single-part field (or AND-chain of them) is left as the join condition verbatim -- the unresolved AST reflects what the user wrote. - Planner (CalciteRelNodeVisitor.visitJoin): the criteria branch detects a bare-field condition and builds the equi-join from it, resolving each field by stack position (LEFT.f = RIGHT.f) via the existing buildJoinConditionByFieldName helper. No new planner code, and no AST mutation. Because resolution is positional rather than by qualifier, a self-join `join on f t` is a genuine cross-scan equi-join rather than the `f = f` tautology a name-based rewrite would collapse to. Adds AstBuilder, Calcite plan, anonymizer, and integration tests; updates the join command user manual. Signed-off-by: Hanyu Wei <weihanyu@amazon.com>
1 parent 2c4215f commit 19e6693

9 files changed

Lines changed: 498 additions & 8 deletions

File tree

core/src/main/java/org/opensearch/sql/calcite/CalciteRelNodeVisitor.java

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1889,10 +1889,22 @@ public RelNode visitJoin(Join node, CalcitePlanContext context) {
18891889
}
18901890
} else {
18911891
// The join-with-criteria grammar doesn't allow empty join condition
1892-
RexNode joinCondition =
1893-
node.getJoinCondition()
1894-
.map(c -> rexVisitor.analyzeJoinCondition(c, context))
1895-
.orElse(context.relBuilder.literal(true));
1892+
RexNode joinCondition;
1893+
List<String> bareFields = new ArrayList<>();
1894+
if (JoinAndLookupUtils.collectBareFields(node.getJoinCondition().get(), bareFields)) {
1895+
// Bare-field shorthand `on a [AND b ...]`. Resolving by stack position rather than by
1896+
// qualifier keeps a self-join `join on f t` a real cross-scan equi-join, not f = f.
1897+
joinCondition =
1898+
bareFields.stream()
1899+
.map(f -> buildJoinConditionByFieldName(context, f))
1900+
.reduce(context.rexBuilder::and)
1901+
.orElse(context.relBuilder.literal(true));
1902+
} else {
1903+
joinCondition =
1904+
node.getJoinCondition()
1905+
.map(c -> rexVisitor.analyzeJoinCondition(c, context))
1906+
.orElse(context.relBuilder.literal(true));
1907+
}
18961908
if (node.getJoinType() == SEMI || node.getJoinType() == ANTI) {
18971909
// semi and anti join only return left table outputs
18981910
context.relBuilder.join(

core/src/main/java/org/opensearch/sql/calcite/utils/JoinAndLookupUtils.java

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@
1313
import org.apache.calcite.rel.core.JoinRelType;
1414
import org.apache.calcite.rex.RexNode;
1515
import org.apache.calcite.util.Pair;
16+
import org.opensearch.sql.ast.expression.And;
17+
import org.opensearch.sql.ast.expression.Field;
18+
import org.opensearch.sql.ast.expression.QualifiedName;
19+
import org.opensearch.sql.ast.expression.UnresolvedExpression;
1620
import org.opensearch.sql.ast.tree.Join;
1721
import org.opensearch.sql.ast.tree.Lookup;
1822
import org.opensearch.sql.calcite.CalcitePlanContext;
@@ -37,6 +41,24 @@ static JoinRelType translateJoinType(Join.JoinType joinType) {
3741
}
3842
}
3943

44+
/**
45+
* Collects the names of bare single-part-field join criteria (e.g. `on a AND b` -> [a, b]).
46+
* Returns false for anything else (qualified field, comparison, OR); {@code out} is only valid
47+
* when this returns true, so callers must check the return before reading it.
48+
*/
49+
static boolean collectBareFields(UnresolvedExpression expr, List<String> out) {
50+
if (expr instanceof And and) {
51+
return collectBareFields(and.getLeft(), out) && collectBareFields(and.getRight(), out);
52+
}
53+
if (expr instanceof Field field
54+
&& field.getField() instanceof QualifiedName qn
55+
&& qn.getParts().size() == 1) {
56+
out.add(qn.getParts().get(0));
57+
return true;
58+
}
59+
return false;
60+
}
61+
4062
/* ------For Lookup------ */
4163

4264
/**

docs/user/ppl/cmd/join.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ source = table1 | left anti join left = l right = r on l.a = r.a table2
2929
source = table1 | join left = l right = r [ source = table2 | where d > 10 | head 5 ]
3030
source = table1 | inner join on table1.a = table2.a table2 | fields table1.a, table2.a, table1.b, table1.c
3131
source = table1 | inner join on a = c table2 | fields a, b, c, d
32+
source = table1 | inner join on a table2 | fields a, table2.a, b, c
33+
source = table1 | inner join on a AND b table2 | fields a, table2.a, b, table2.b
34+
source = table1 | join on a table2 | fields a, table2.a, b, c
3235
source = table1 as t1 | join left = l right = r on l.a = r.a table2 as t2 | fields l.a, r.a
3336
source = table1 as t1 | join left = l right = r on l.a = r.a table2 as t2 | fields t1.a, t2.a
3437
source = table1 | join left = l right = r on l.a = r.a [ source = table2 ] as s | fields l.a, s.a
@@ -40,7 +43,7 @@ The basic `join` syntax supports the following parameters.
4043

4144
| Parameter | Required/Optional | Description |
4245
| --- | --- | --- |
43-
| `<joinCriteria>` | Required | A comparison expression specifying how to join the datasets. Must be placed after the `on` or `where` keyword in the query. |
46+
| `<joinCriteria>` | Required | A comparison expression specifying how to join the datasets. Must be placed after the `on` or `where` keyword in the query. A bare field name `f` (or `f AND g ...`) is shorthand for an equi-join on the common field(s) and keeps both key columns. |
4447
| `<right-dataset>` | Required | The right dataset, which can be an index or a subsearch, with or without an alias. |
4548
| `joinType` | Optional | The type of join to perform. Valid values are `left`, `semi`, `anti`, and performance-sensitive types (`right`, `full`, and `cross`). Default is `inner`. |
4649
| `left` | Optional | An alias for the left dataset (typically a subsearch) used to avoid ambiguous field names. Specify as `left = <leftAlias>`. |
@@ -71,7 +74,7 @@ The extended `join` syntax supports the following parameters.
7174

7275
| Parameter | Required/Optional | Description |
7376
| --- | --- | --- |
74-
| `<joinCriteria>` | Required | A comparison expression specifying how to join the datasets. Must be placed after the `on` or `where` keyword in the query. |
77+
| `<joinCriteria>` | Required | A comparison expression specifying how to join the datasets. Must be placed after the `on` or `where` keyword in the query. A bare field name `f` (or `f AND g ...`) is shorthand for an equi-join on the common field(s); it keeps both key columns and differs from `<join-field-list>`, which merges duplicates. |
7578
| `<right-dataset>` | Required | The right dataset, which can be an index or a subsearch, with or without an alias. |
7679
| `type` | Optional | The join type when using extended syntax. Valid values are `left`, `outer` (same as `left`), `semi`, `anti`, and performance-sensitive types (`right`, `full`, and `cross`). Default is `inner`. |
7780
| `<join-field-list>` | Optional | A list of fields used to build the join criteria. These fields must exist in both datasets. If not specified, all fields common to both datasets are used as join keys. |

integ-test/src/test/java/org/opensearch/sql/calcite/remote/CalcitePPLJoinIT.java

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -928,6 +928,106 @@ public void testJoinWhenLegacyNotPreferred() throws IOException {
928928
});
929929
}
930930

931+
@Test
932+
public void testJoinWithImplicitField() throws IOException {
933+
// Keep-both, so co-named columns (other than the key) resolve to the left side.
934+
JSONObject actual =
935+
executeQuery(
936+
String.format(
937+
"source=%s | inner join on name %s | fields name, age, state, country, occupation,"
938+
+ " salary",
939+
TEST_INDEX_STATE_COUNTRY, TEST_INDEX_OCCUPATION));
940+
verifySchema(
941+
actual,
942+
schema("name", "string"),
943+
schema("age", "int"),
944+
schema("state", "string"),
945+
schema("country", "string"),
946+
schema("occupation", "string"),
947+
schema("salary", "int"));
948+
verifyDataRows(
949+
actual,
950+
rows("Jake", 70, "California", "USA", "Engineer", 100000),
951+
rows("Hello", 30, "New York", "USA", "Artist", 70000),
952+
rows("John", 25, "Ontario", "Canada", "Doctor", 120000),
953+
rows("Jane", 20, "Quebec", "Canada", "Scientist", 90000),
954+
rows("David", 40, "Washington", "USA", "Doctor", 120000),
955+
rows("David", 40, "Washington", "USA", "Unemployed", 0));
956+
957+
// The shorthand and the explicit qualified-equality form are output-identical.
958+
JSONObject explicitForm =
959+
executeQuery(
960+
String.format(
961+
"source=%s | inner join on %s.name = %s.name %s | fields name, age, state, country,"
962+
+ " occupation, salary",
963+
TEST_INDEX_STATE_COUNTRY,
964+
TEST_INDEX_STATE_COUNTRY,
965+
TEST_INDEX_OCCUPATION,
966+
TEST_INDEX_OCCUPATION));
967+
assertJsonEquals(explicitForm.toString(), actual.toString());
968+
}
969+
970+
@Test
971+
public void testJoinNoPrefixImplicitField() throws IOException {
972+
// No-prefix `join on name` matches the explicit qualified form, not the field-list `join name`.
973+
JSONObject actual =
974+
executeQuery(
975+
String.format(
976+
"source=%s | join on name %s | fields name, age, state, occupation, salary",
977+
TEST_INDEX_STATE_COUNTRY, TEST_INDEX_OCCUPATION));
978+
JSONObject explicitForm =
979+
executeQuery(
980+
String.format(
981+
"source=%s | join on %s.name = %s.name %s | fields name, age, state, occupation,"
982+
+ " salary",
983+
TEST_INDEX_STATE_COUNTRY,
984+
TEST_INDEX_STATE_COUNTRY,
985+
TEST_INDEX_OCCUPATION,
986+
TEST_INDEX_OCCUPATION));
987+
assertJsonEquals(explicitForm.toString(), actual.toString());
988+
}
989+
990+
@Test
991+
public void testLeftJoinWithImplicitField() throws IOException {
992+
// Keep-both does not coalesce, so unmatched-left rows keep the non-null left key, not a NULL.
993+
JSONObject actual =
994+
executeQuery(
995+
String.format(
996+
"source=%s | left join on name %s | fields name, age, state, occupation, salary",
997+
TEST_INDEX_STATE_COUNTRY, TEST_INDEX_OCCUPATION));
998+
verifySchema(
999+
actual,
1000+
schema("name", "string"),
1001+
schema("age", "int"),
1002+
schema("state", "string"),
1003+
schema("occupation", "string"),
1004+
schema("salary", "int"));
1005+
verifyDataRows(
1006+
actual,
1007+
rows("Jake", 70, "California", "Engineer", 100000),
1008+
rows("Hello", 30, "New York", "Artist", 70000),
1009+
rows("John", 25, "Ontario", "Doctor", 120000),
1010+
rows("Jane", 20, "Quebec", "Scientist", 90000),
1011+
rows("David", 40, "Washington", "Doctor", 120000),
1012+
rows("David", 40, "Washington", "Unemployed", 0),
1013+
rows("Jim", 27, "B.C", null, null),
1014+
rows("Peter", 57, "B.C", null, null),
1015+
rows("Rick", 70, "B.C", null, null));
1016+
}
1017+
1018+
@Test
1019+
public void testAliasedSelfJoinWithImplicitField() throws IOException {
1020+
// Self-join on the unique `name`: a real equi-join matches each of the 8 rows to itself (8
1021+
// rows), not the 64-row cross product a tautology would give.
1022+
JSONObject actual =
1023+
executeQuery(
1024+
String.format(
1025+
"source=%s | inner join left=l right=r on name %s | fields name, r.name",
1026+
TEST_INDEX_STATE_COUNTRY, TEST_INDEX_STATE_COUNTRY));
1027+
verifyNumOfRows(actual, 8);
1028+
verifySchema(actual, schema("name", "string"), schema("r.name", "string"));
1029+
}
1030+
9311031
@Test
9321032
public void testJoinComparing() throws IOException {
9331033
JSONObject actual =

ppl/src/main/antlr/OpenSearchPPLParser.g4

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -729,8 +729,9 @@ sourceFilterArg
729729

730730
// join
731731
joinCommand
732-
: JOIN (joinOption)* (fieldList)? right = tableOrSubqueryClause
733-
| sqlLikeJoinType? JOIN (joinOption)* sideAlias joinHintList? joinCriteria right = tableOrSubqueryClause
732+
// Criteria alt listed first - so `join on a` reads `on` as the criteria keyword, not a field.
733+
: sqlLikeJoinType? JOIN (joinOption)* sideAlias joinHintList? joinCriteria right = tableOrSubqueryClause
734+
| JOIN (joinOption)* (fieldList)? right = tableOrSubqueryClause
734735
;
735736

736737
sqlLikeJoinType

ppl/src/main/java/org/opensearch/sql/ppl/parser/AstBuilder.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,8 @@ public UnresolvedPlan visitJoinCommand(OpenSearchPPLParser.JoinCommandContext ct
326326
if (ctx.fieldList() != null) {
327327
joinFields = Optional.of(getFieldList(ctx.fieldList()));
328328
}
329+
// Keep a bare `on <field>` criteria verbatim; the planner expands it to an equi-join. Folding
330+
// it into joinFields here would instead merge the key into one column.
329331
return new Join(
330332
projectExceptMeta(right),
331333
leftAlias,

0 commit comments

Comments
 (0)