Skip to content

Commit 81dac7f

Browse files
Add more UTs and bugfixes
1 parent d253a55 commit 81dac7f

File tree

10 files changed

+377
-49
lines changed

10 files changed

+377
-49
lines changed

.idea/runConfigurations/Debug_Elasticsearch__node_2_.xml

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

.idea/runConfigurations/Debug_Elasticsearch__node_3_.xml

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

x-pack/plugin/esql/qa/server/multi-clusters/src/javaRestTest/java/org/elasticsearch/xpack/esql/ccq/MultiClusterSpecIT.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ public MultiClusterSpecIT(
118118
"LookupJoinOnTwoFieldsMultipleTimes",
119119
// Lookup join after LIMIT is not supported in CCS yet
120120
"LookupJoinAfterLimitAndRemoteEnrich",
121+
"LookupJoinExpressionAfterLimitAndRemoteEnrich",
121122
// Lookup join after FORK is not support in CCS yet
122123
"ForkBeforeLookupJoin"
123124
);

x-pack/plugin/esql/qa/testFixtures/src/main/resources/lookup-join-expression.csv-spec

Lines changed: 245 additions & 0 deletions
Large diffs are not rendered by default.

x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/analysis/Analyzer.java

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -720,7 +720,11 @@ private LogicalPlan resolveLookup(Lookup l, List<Attribute> childrenOutput) {
720720
return l;
721721
}
722722

723-
private List<Expression> resolveJoinFilters(List<Expression> filters, List<Attribute> leftOutput, List<Attribute> rightOutput) {
723+
private List<Expression> resolveJoinFiltersAndSwapIfNeeded(
724+
List<Expression> filters,
725+
List<Attribute> leftOutput,
726+
List<Attribute> rightOutput
727+
) {
724728
if (filters.isEmpty()) {
725729
return emptyList();
726730
}
@@ -729,11 +733,39 @@ private List<Expression> resolveJoinFilters(List<Expression> filters, List<Attri
729733

730734
List<Expression> resolvedFilters = new ArrayList<>(filters.size());
731735
for (Expression filter : filters) {
732-
resolvedFilters.add(filter.transformUp(UnresolvedAttribute.class, ua -> maybeResolveAttribute(ua, childrenOutput)));
736+
Expression filterResolved = filter.transformUp(UnresolvedAttribute.class, ua -> maybeResolveAttribute(ua, childrenOutput));
737+
resolvedFilters.add(resolveAndOrientJoinCondition(filterResolved, leftOutput, rightOutput));
733738
}
734739
return resolvedFilters;
735740
}
736741

742+
private Expression resolveAndOrientJoinCondition(Expression condition, List<Attribute> leftOutput, List<Attribute> rightOutput) {
743+
if (condition instanceof EsqlBinaryComparison comp
744+
&& comp.left() instanceof Attribute leftAttr
745+
&& comp.right() instanceof Attribute rightAttr) {
746+
747+
boolean leftIsFromLeft = leftOutput.contains(leftAttr);
748+
boolean rightIsFromRight = rightOutput.contains(rightAttr);
749+
750+
if (leftIsFromLeft && rightIsFromRight) {
751+
return comp; // Correct orientation
752+
}
753+
754+
boolean leftIsFromRight = rightOutput.contains(leftAttr);
755+
boolean rightIsFromLeft = leftOutput.contains(rightAttr);
756+
757+
if (leftIsFromRight && rightIsFromLeft) {
758+
return comp.swapLeftAndRight(); // Swapped orientation
759+
}
760+
761+
// Invalid orientation (e.g., both from left or both from right)
762+
throw new EsqlIllegalArgumentException(
763+
"Join condition must be between attributes on the left and right side, but found: " + condition.sourceText()
764+
);
765+
}
766+
return condition; // Not a binary comparison between two attributes, no change needed.
767+
}
768+
737769
private Join resolveLookupJoin(LookupJoin join) {
738770
JoinConfig config = join.config();
739771
// for now, support only (LEFT) USING clauses
@@ -763,16 +795,19 @@ private Join resolveLookupJoin(LookupJoin join) {
763795
List<Expression> resolvedFilters = new ArrayList<>();
764796
List<Attribute> matchKeys;
765797
if (join.config().joinOnConditions() != null) {
766-
resolvedFilters = resolveJoinFilters(
798+
resolvedFilters = resolveJoinFiltersAndSwapIfNeeded(
767799
Predicates.splitAnd(join.config().joinOnConditions()),
768800
join.left().output(),
769801
join.right().output()
770802
);
771-
// build leftKeys and rightKeys using the left side of the resolvedFilters.
803+
// build leftKeys and rightKeys using the correct side of the resolvedFilters.
804+
// resolveJoinFiltersAndSwapIfNeeded already put the left and right on the correct side
772805
for (Expression expression : resolvedFilters) {
773-
if (expression instanceof EsqlBinaryComparison binaryComparison) {
774-
leftKeys.add((Attribute) binaryComparison.left());
775-
rightKeys.add((Attribute) binaryComparison.right());
806+
if (expression instanceof EsqlBinaryComparison binaryComparison
807+
&& binaryComparison.left() instanceof Attribute leftAttribute
808+
&& binaryComparison.right() instanceof Attribute rightAttribute) {
809+
leftKeys.add(leftAttribute);
810+
rightKeys.add(rightAttribute);
776811
} else {
777812
throw new EsqlIllegalArgumentException("Unsupported join filter expression: " + expression);
778813
}

x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/enrich/ExpressionQueryList.java

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ public ExpressionQueryList(
6363
if (queryLists.size() < 2 && (rightPreJoinPlan instanceof FilterExec == false) && request.getJoinOnConditions() == null) {
6464
throw new IllegalArgumentException("ExpressionQueryList must have at least two QueryLists or a pre-join filter");
6565
}
66-
this.queryLists = queryLists;
66+
this.queryLists = new ArrayList<>(queryLists);
6767
this.context = context;
6868
this.aliasFilter = aliasFilter;
6969
buildJoinOnConditions(request, clusterService, warnings);
@@ -80,10 +80,8 @@ private void buildJoinOnConditions(LookupFromIndexService.TransportRequest reque
8080
// the join on conditions are already populated via the queryLists
8181
// there is nothing to do here
8282
return;
83-
} else {
84-
// clear the join on conditions in the query lists
85-
// the join on condition needs to come from the expression
86-
queryLists.clear();
83+
} else if (queryLists.isEmpty() == false) {
84+
throw new IllegalArgumentException("ExpressionQueryList called with both join on expression and join on fields");
8785
}
8886
List<Expression> expressions = Predicates.splitAnd(filter);
8987
for (Expression expr : expressions) {

x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/enrich/LookupFromIndexService.java

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -110,25 +110,28 @@ protected LookupEnrichQueryGenerator queryList(
110110
Block inputBlock,
111111
Warnings warnings
112112
) {
113-
List<QueryList> queryLists = new ArrayList<>();
114-
for (int i = 0; i < request.matchFields.size(); i++) {
115-
MatchConfig matchField = request.matchFields.get(i);
116-
QueryList q = termQueryList(
117-
context.getFieldType(matchField.fieldName().string()),
118-
context,
119-
aliasFilter,
120-
request.inputPage.getBlock(matchField.channel()),
121-
matchField.type()
122-
).onlySingleValues(warnings, "LOOKUP JOIN encountered multi-value");
123-
queryLists.add(q);
124-
}
113+
if (request.joinOnConditions == null) {
114+
List<QueryList> queryLists = new ArrayList<>();
115+
for (int i = 0; i < request.matchFields.size(); i++) {
116+
MatchConfig matchField = request.matchFields.get(i);
117+
QueryList q = termQueryList(
118+
context.getFieldType(matchField.fieldName().string()),
119+
context,
120+
aliasFilter,
121+
request.inputPage.getBlock(matchField.channel()),
122+
matchField.type()
123+
).onlySingleValues(warnings, "LOOKUP JOIN encountered multi-value");
124+
queryLists.add(q);
125+
}
125126

126-
PhysicalPlan physicalPlan = request.rightPreJoinPlan;
127-
physicalPlan = localLookupNodePlanning(physicalPlan);
128-
if (queryLists.size() == 1 && physicalPlan instanceof FilterExec == false && request.joinOnConditions == null) {
129-
return queryLists.getFirst();
127+
PhysicalPlan physicalPlan = localLookupNodePlanning(request.rightPreJoinPlan);
128+
if (queryLists.size() == 1 && physicalPlan instanceof FilterExec == false) {
129+
return queryLists.getFirst();
130+
}
131+
return new ExpressionQueryList(queryLists, context, physicalPlan, clusterService, request, aliasFilter, warnings);
130132
}
131-
return new ExpressionQueryList(queryLists, context, physicalPlan, clusterService, request, aliasFilter, warnings);
133+
PhysicalPlan physicalPlan = localLookupNodePlanning(request.rightPreJoinPlan);
134+
return new ExpressionQueryList(List.of(), context, physicalPlan, clusterService, request, aliasFilter, warnings);
132135

133136
}
134137

x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/parser/LogicalPlanBuilder.java

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -715,7 +715,6 @@ public JoinInfo visitExpressionBasedLookupJoin(EsqlBaseParser.ExpressionBasedLoo
715715
);
716716
}
717717
}
718-
validateJoinFields(joinFields);
719718
return new JoinInfo(joinFields, joinExpressions);
720719
}
721720

x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plan/logical/join/Join.java

Lines changed: 3 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -232,23 +232,9 @@ public static List<Attribute> computeOutput(List<Attribute> leftOutput, List<Att
232232
.toList();
233233
output = mergeOutputAttributes(rightOutputWithoutMatchFields, leftOutput);
234234
} else {
235-
// suppose we have
236-
// index1 a, b, c, d
237-
// index2 c, d, e, f
238-
// we have expression join on (index1.b > index2.c AND index1.d > index2.d)
239-
// We keep a,b,d from the left side (a, b only on left, d is join key, so we keep it from the left)
240-
// we keep c,e,f from the right side(c is not part of the join key on the left so gets shadowed, e,f only on right)
241-
Set<String> leftJoinKeyNames = config.leftFields()
242-
.stream()
243-
.map(Attribute::name)
244-
.collect(java.util.stream.Collectors.toSet());
245-
// as we can do (left_key > right_key) now as join condition, we want to preserve the right_key
246-
// unless it is also a key on the left side,
247-
// but we need to do name equality only
248-
List<Attribute> rightOutputWithoutMatchFields = rightOutput.stream()
249-
.filter(attr -> leftJoinKeyNames.contains(attr.name()) == false)
250-
.toList();
251-
output = mergeOutputAttributes(rightOutputWithoutMatchFields, leftOutput);
235+
// We don't allow any attributes in the joinOnConditions that don't have unique names
236+
// so right always overwrites left in case of name clashes
237+
output = mergeOutputAttributes(rightOutput, leftOutput);
252238
}
253239

254240
} else {

x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/LogicalPlanOptimizerTests.java

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8994,4 +8994,65 @@ public void testTranslateMetricsGroupedByTBucketInTSMode() {
89948994
Bucket bucket = as(Alias.unwrap(eval.fields().get(0)), Bucket.class);
89958995
assertThat(Expressions.attribute(bucket.field()).name(), equalTo("@timestamp"));
89968996
}
8997+
8998+
/**
8999+
* Limit[1000[INTEGER],true]
9000+
* \_Join[LEFT,[languages{f}#8, language_code{f}#16],[languages{f}#8],[language_code{f}#16],languages{f}#8 == language_code{
9001+
* f}#16]
9002+
* |_EsqlProject[[languages{f}#8]]
9003+
* | \_Limit[1000[INTEGER],false]
9004+
* | \_EsRelation[test][_meta_field{f}#11, emp_no{f}#5, first_name{f}#6, ge..]
9005+
* \_EsRelation[languages_lookup][LOOKUP][language_code{f}#16, language_name{f}#17]
9006+
*/
9007+
public void testLookupJoinExpressionSwapped() {
9008+
LogicalPlan plan = optimizedPlan("""
9009+
from test
9010+
| keep languages
9011+
| lookup join languages_lookup ON language_code == languages
9012+
""");
9013+
9014+
var limit = asLimit(plan, 1000, true);
9015+
var join = as(limit.child(), Join.class);
9016+
assertEquals("language_code == languages", join.config().joinOnConditions().toString());
9017+
var equals = as(join.config().joinOnConditions(), Equals.class);
9018+
// we expect left and right to be swapped
9019+
var left = as(equals.left(), Attribute.class);
9020+
var right = as(equals.right(), Attribute.class);
9021+
assertEquals("language_code", right.name());
9022+
assertEquals("languages", left.name());
9023+
var project = as(join.left(), EsqlProject.class);
9024+
var limitPastJoin = asLimit(project.child(), 1000, false);
9025+
as(limitPastJoin.child(), EsRelation.class);
9026+
as(join.right(), EsRelation.class);
9027+
}
9028+
9029+
public void testLookupJoinExpressionAmbigiousRight() {
9030+
String query = """
9031+
from test
9032+
| rename languages as language_code
9033+
| lookup join languages_lookup ON salary == language_code
9034+
""";
9035+
IllegalStateException e = expectThrows(IllegalStateException.class, () -> plan(query));
9036+
assertThat(e.getMessage(), containsString("Reference [language_code] is ambiguous; matches any of "));
9037+
}
9038+
9039+
public void testLookupJoinExpressionAmbigiousLeft() {
9040+
String query = """
9041+
from test
9042+
| rename languages as language_name
9043+
| lookup join languages_lookup ON language_name == language_code
9044+
""";
9045+
IllegalStateException e = expectThrows(IllegalStateException.class, () -> plan(query));
9046+
assertThat(e.getMessage(), containsString("Reference [language_name] is ambiguous; matches any of "));
9047+
}
9048+
9049+
public void testLookupJoinExpressionAmbigiousBoth() {
9050+
String query = """
9051+
from test
9052+
| rename languages as language_code
9053+
| lookup join languages_lookup ON language_code != language_code
9054+
""";
9055+
IllegalStateException e = expectThrows(IllegalStateException.class, () -> plan(query));
9056+
assertThat(e.getMessage(), containsString("Reference [language_code] is ambiguous; matches any of "));
9057+
}
89979058
}

0 commit comments

Comments
 (0)