This is an automated email from the ASF dual-hosted git repository.
zzwqqq pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/calcite.git
The following commit(s) were added to refs/heads/main by this push:
new 152a4208c5 [CALCITE-7755] Support IEJoin for inequality joins
152a4208c5 is described below
commit 152a4208c54ecbcea74b9555644f804f764912eb
Author: zzwqqq <[email protected]>
AuthorDate: Sun Aug 30 14:49:00 2026 +0800
[CALCITE-7755] Support IEJoin for inequality joins
---
.../adapter/enumerable/EnumerableIEJoin.java | 287 +++++++++++++
.../adapter/enumerable/EnumerableIEJoinRule.java | 111 +++++
.../adapter/enumerable/EnumerableRules.java | 7 +
.../java/org/apache/calcite/tools/Programs.java | 1 +
.../org/apache/calcite/util/BuiltInMethod.java | 5 +
.../test/enumerable/EnumerableIEJoinTest.java | 451 +++++++++++++++++++++
.../apache/calcite/test/CombineRelOptRulesTest.xml | 4 +-
.../apache/calcite/linq4j/EnumerableDefaults.java | 45 ++
.../apache/calcite/linq4j/IEJoinEnumerator.java | 286 +++++++++++++
.../org/apache/calcite/linq4j/test/IEJoinTest.java | 245 +++++++++++
10 files changed, 1440 insertions(+), 2 deletions(-)
diff --git
a/core/src/main/java/org/apache/calcite/adapter/enumerable/EnumerableIEJoin.java
b/core/src/main/java/org/apache/calcite/adapter/enumerable/EnumerableIEJoin.java
new file mode 100644
index 0000000000..197c3ab3d9
--- /dev/null
+++
b/core/src/main/java/org/apache/calcite/adapter/enumerable/EnumerableIEJoin.java
@@ -0,0 +1,287 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to you under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.calcite.adapter.enumerable;
+
+import org.apache.calcite.adapter.java.JavaTypeFactory;
+import org.apache.calcite.linq4j.function.Function1;
+import org.apache.calcite.linq4j.tree.BlockBuilder;
+import org.apache.calcite.linq4j.tree.Expression;
+import org.apache.calcite.linq4j.tree.ExpressionType;
+import org.apache.calcite.linq4j.tree.Expressions;
+import org.apache.calcite.linq4j.tree.ParameterExpression;
+import org.apache.calcite.plan.RelOptCluster;
+import org.apache.calcite.plan.RelOptCost;
+import org.apache.calcite.plan.RelOptPlanner;
+import org.apache.calcite.plan.RelOptUtil;
+import org.apache.calcite.plan.RelTraitSet;
+import org.apache.calcite.rel.RelCollations;
+import org.apache.calcite.rel.RelFieldCollation;
+import org.apache.calcite.rel.RelNode;
+import org.apache.calcite.rel.RelNodes;
+import org.apache.calcite.rel.core.Join;
+import org.apache.calcite.rel.core.JoinRelType;
+import org.apache.calcite.rel.metadata.RelMdUtil;
+import org.apache.calcite.rel.metadata.RelMetadataQuery;
+import org.apache.calcite.rel.type.RelDataType;
+import org.apache.calcite.rex.RexCall;
+import org.apache.calcite.rex.RexInputRef;
+import org.apache.calcite.rex.RexNode;
+import org.apache.calcite.sql.type.SqlTypeName;
+import org.apache.calcite.sql.type.SqlTypeUtil;
+import org.apache.calcite.util.BuiltInMethod;
+import org.apache.calcite.util.Util;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+
+import org.checkerframework.checker.nullness.qual.Nullable;
+
+import java.lang.reflect.Type;
+import java.util.ArrayList;
+import java.util.List;
+
+import static java.util.Objects.requireNonNull;
+
+/** Implementation of an inner IEJoin with two inequality predicates in
+ * {@link EnumerableConvention enumerable calling convention}. */
+public class EnumerableIEJoin extends Join implements EnumerableRel {
+ private final ImmutableList<Condition> conditions;
+
+ protected EnumerableIEJoin(RelOptCluster cluster, RelTraitSet traitSet,
+ RelNode left, RelNode right, RexNode condition) {
+ super(cluster, traitSet, ImmutableList.of(), left, right, condition,
+ ImmutableSet.of(), JoinRelType.INNER);
+ final List<RexNode> conjunctions = RelOptUtil.conjunctions(condition);
+ if (conjunctions.size() != 2) {
+ throw new IllegalArgumentException(
+ "condition must contain exactly two supported cross-input
inequalities");
+ }
+ final int leftFieldCount = left.getRowType().getFieldCount();
+ final Condition first =
+ analyzeConjunction(conjunctions.get(0), leftFieldCount);
+ final Condition second =
+ analyzeConjunction(conjunctions.get(1), leftFieldCount);
+ if (first == null || second == null) {
+ throw new IllegalArgumentException(
+ "condition must contain supported cross-input inequalities");
+ }
+ final ImmutableList<Condition> inequalities = ImmutableList.of(first,
second);
+ for (Condition inequality : inequalities) {
+ if (!supportsKeyTypes(left, right, inequality)) {
+ throw new IllegalArgumentException("unsupported IEJoin key types: left
"
+ +
left.getRowType().getFieldList().get(inequality.leftKey).getType()
+ + ", right "
+ +
right.getRowType().getFieldList().get(inequality.rightKey).getType());
+ }
+ }
+ this.conditions = inequalities;
+ }
+
+ /** Creates an EnumerableIEJoin. */
+ public static EnumerableIEJoin create(RelNode left, RelNode right,
+ RexNode condition) {
+ return new EnumerableIEJoin(left.getCluster(),
+ left.getCluster().traitSetOf(EnumerableConvention.INSTANCE),
+ left, right, condition);
+ }
+
+ @Override public EnumerableIEJoin copy(RelTraitSet traitSet,
+ RexNode condition, RelNode left, RelNode right, JoinRelType joinType,
+ boolean semiJoinDone) {
+ if (joinType != JoinRelType.INNER) {
+ throw new IllegalArgumentException("EnumerableIEJoin only supports inner
joins");
+ }
+ return new EnumerableIEJoin(getCluster(), traitSet, left, right,
+ condition);
+ }
+
+ @Override public @Nullable RelOptCost computeSelfCost(RelOptPlanner planner,
+ RelMetadataQuery mq) {
+ final Double leftRows = mq.getRowCount(left);
+ final Double rightRows = mq.getRowCount(right);
+ final Double joinRows = mq.getRowCount(this);
+ if (leftRows == null || rightRows == null || joinRows == null) {
+ return null;
+ }
+ double outputRows = joinRows;
+ if (RelNodes.COMPARATOR.compare(left, right) > 0) {
+ outputRows = RelMdUtil.addEpsilon(outputRows);
+ }
+ final double inputRows = leftRows + rightRows;
+ // Sort the combined inputs by each inequality key, then scan and emit
pairs.
+ final double cost =
+ 2D * Util.nLogN(inputRows) + inputRows + outputRows;
+ return planner.getCostFactory().makeCost(cost, 0, 0);
+ }
+
+ @Override public Result implement(EnumerableRelImplementor implementor,
+ Prefer pref) {
+ final BlockBuilder builder = new BlockBuilder();
+ final Result leftResult =
+ implementor.visitChild(this, 0, (EnumerableRel) left, pref);
+ final Expression leftExpression =
+ builder.append("left", leftResult.block);
+ final Result rightResult =
+ implementor.visitChild(this, 1, (EnumerableRel) right, pref);
+ final Expression rightExpression =
+ builder.append("right", rightResult.block);
+ final ParameterExpression leftParameter =
+ Expressions.parameter(leftResult.physType.getJavaRowType(), "leftRow");
+ final ParameterExpression rightParameter =
+ Expressions.parameter(rightResult.physType.getJavaRowType(),
"rightRow");
+ final JavaTypeFactory typeFactory = implementor.getTypeFactory();
+ final List<Expression> keySelectors = new ArrayList<>();
+ final List<Expression> comparators = new ArrayList<>();
+
+ for (Condition condition : conditions) {
+ final RelDataType leftType =
+ left.getRowType().getFieldList().get(condition.leftKey).getType();
+ final RelDataType rightType =
+ right.getRowType().getFieldList().get(condition.rightKey).getType();
+ // Use SQL storage types so timestamp comparisons use millisecond
precision.
+ final RelDataType keyType =
+ typeFactory.toSql(
+
requireNonNull(typeFactory.leastRestrictive(ImmutableList.of(leftType,
rightType))));
+ final Type keyClass = typeFactory.getJavaClass(keyType);
+ // For nullable INTEGER keys in array rows:
+ // leftRow -> (Integer) leftRow[leftKey]
+ // rightRow -> (Integer) rightRow[rightKey]
+ keySelectors.add(
+ Expressions.lambda(
+ Function1.class,
+ EnumUtils.convert(
+ leftResult.physType.fieldReference(
+ leftParameter, condition.leftKey), keyClass), leftParameter));
+ keySelectors.add(
+ Expressions.lambda(
+ Function1.class,
+ EnumUtils.convert(
+ rightResult.physType.fieldReference(
+ rightParameter, condition.rightKey), keyClass), rightParameter));
+ // PhysType generates comparators for row fields, so wrap the key in a
+ // scalar row type.
+ // For nullable INTEGER keys: (a, b) -> Utilities.compareNullsLast(a, b)
+ final RelDataType keyRowType =
+ typeFactory.builder().add("key", keyType).build();
+ final PhysType keyPhysType =
+ PhysTypeImpl.of(typeFactory, keyRowType, JavaRowFormat.SCALAR);
+ comparators.add(
+ keyPhysType.generateComparator(
+ RelCollations.of(
+ new RelFieldCollation(0,
+ RelFieldCollation.Direction.ASCENDING,
+ RelFieldCollation.NullDirection.LAST))));
+ }
+
+ final PhysType physType =
+ PhysTypeImpl.of(typeFactory, getRowType(), pref.preferArray());
+ final List<Expression> arguments = new ArrayList<>();
+ arguments.add(leftExpression);
+ arguments.add(rightExpression);
+ arguments.addAll(keySelectors);
+ arguments.addAll(comparators);
+ arguments.add(Expressions.constant(conditions.get(0).operator));
+ arguments.add(Expressions.constant(conditions.get(1).operator));
+ // For two-field array rows, the result selector is:
+ // (leftRow, rightRow) -> new Object[] {
+ // leftRow[0], leftRow[1], rightRow[0], rightRow[1] }
+ arguments.add(
+ EnumUtils.joinSelector(joinType, physType,
+ ImmutableList.of(leftResult.physType, rightResult.physType)));
+
+ // return EnumerableDefaults.ieJoin(left, right,
+ // leftKey1, rightKey1, leftKey2, rightKey2,
+ // comparator1, comparator2, operator1, operator2, resultSelector);
+ return implementor.result(physType,
+ builder.append(
+ Expressions.call(BuiltInMethod.IE_JOIN.method,
+ arguments)).toBlock());
+ }
+
+ static @Nullable Condition analyzeConjunction(RexNode node,
+ int leftFieldCount) {
+ if (!(node instanceof RexCall) || ((RexCall) node).operands.size() != 2) {
+ return null;
+ }
+ final RexCall call = (RexCall) node;
+ if (!(call.operands.get(0) instanceof RexInputRef)
+ || !(call.operands.get(1) instanceof RexInputRef)) {
+ return null;
+ }
+ final int first = ((RexInputRef) call.operands.get(0)).getIndex();
+ final int second = ((RexInputRef) call.operands.get(1)).getIndex();
+ final boolean firstIsLeft = first < leftFieldCount;
+ final boolean secondIsLeft = second < leftFieldCount;
+ if (firstIsLeft == secondIsLeft) {
+ return null;
+ }
+ final ExpressionType operator;
+ switch (firstIsLeft ? call.getKind() : call.getKind().reverse()) {
+ case LESS_THAN:
+ operator = ExpressionType.LessThan;
+ break;
+ case LESS_THAN_OR_EQUAL:
+ operator = ExpressionType.LessThanOrEqual;
+ break;
+ case GREATER_THAN:
+ operator = ExpressionType.GreaterThan;
+ break;
+ case GREATER_THAN_OR_EQUAL:
+ operator = ExpressionType.GreaterThanOrEqual;
+ break;
+ default:
+ return null;
+ }
+ return firstIsLeft
+ ? new Condition(first, second - leftFieldCount, operator)
+ : new Condition(second, first - leftFieldCount, operator);
+ }
+
+ static boolean supportsKeyTypes(RelNode left, RelNode right,
+ Condition condition) {
+ final RelDataType leftType =
+ left.getRowType().getFieldList().get(condition.leftKey).getType();
+ final RelDataType rightType =
+ right.getRowType().getFieldList().get(condition.rightKey).getType();
+ final SqlTypeName typeName = leftType.getSqlTypeName();
+ // Floating-point sort order disagrees with <, <=, > and >= for NaN
+ // and signed zero.
+ return SqlTypeUtil.equalSansNullability(
+ left.getCluster().getTypeFactory(), leftType, rightType)
+ && (SqlTypeUtil.isBoolean(leftType)
+ || (SqlTypeUtil.isExactNumeric(leftType)
+ && !SqlTypeName.UNSIGNED_TYPES.contains(typeName))
+ || SqlTypeUtil.isCharacter(leftType)
+ || SqlTypeUtil.isBinary(leftType)
+ || SqlTypeUtil.isDatetime(leftType)
+ || SqlTypeUtil.isInterval(leftType));
+ }
+
+ /** A normalized IEJoin condition. */
+ static final class Condition {
+ final int leftKey;
+ final int rightKey;
+ final ExpressionType operator;
+
+ private Condition(int leftKey, int rightKey,
+ ExpressionType operator) {
+ this.leftKey = leftKey;
+ this.rightKey = rightKey;
+ this.operator = operator;
+ }
+ }
+}
diff --git
a/core/src/main/java/org/apache/calcite/adapter/enumerable/EnumerableIEJoinRule.java
b/core/src/main/java/org/apache/calcite/adapter/enumerable/EnumerableIEJoinRule.java
new file mode 100644
index 0000000000..5023fc5e57
--- /dev/null
+++
b/core/src/main/java/org/apache/calcite/adapter/enumerable/EnumerableIEJoinRule.java
@@ -0,0 +1,111 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to you under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.calcite.adapter.enumerable;
+
+import org.apache.calcite.plan.Convention;
+import org.apache.calcite.plan.RelOptUtil;
+import org.apache.calcite.rel.RelNode;
+import org.apache.calcite.rel.convert.ConverterRule;
+import org.apache.calcite.rel.core.Join;
+import org.apache.calcite.rel.core.JoinRelType;
+import org.apache.calcite.rel.logical.LogicalJoin;
+import org.apache.calcite.rex.RexBuilder;
+import org.apache.calcite.rex.RexNode;
+import org.apache.calcite.rex.RexProgram;
+import org.apache.calcite.rex.RexUtil;
+
+import org.checkerframework.checker.nullness.qual.Nullable;
+
+import java.util.List;
+
+import static java.util.Objects.requireNonNull;
+
+/** Planner rule that converts an inner {@link LogicalJoin} whose condition
+ * consists of at least two cross-input field inequalities to an
+ * {@link EnumerableIEJoin}.
+ *
+ * <p>Based on Khayyat et al.,
+ * <a href="https://doi.org/10.14778/2831360.2831362">"Lightning Fast and Space
+ * Efficient Inequality Joins," PVLDB 8(13), 2015</a>. The first two
+ * inequalities drive the join and additional inequalities are evaluated by an
+ * {@link EnumerableCalc}.
+ *
+ * @see EnumerableRules#ENUMERABLE_IE_JOIN_RULE
+ */
+class EnumerableIEJoinRule extends ConverterRule {
+ /** Default configuration. */
+ static final Config DEFAULT_CONFIG = Config.INSTANCE
+ .withConversion(LogicalJoin.class, Convention.NONE,
+ EnumerableConvention.INSTANCE, "EnumerableIEJoinRule")
+ .withRuleFactory(EnumerableIEJoinRule::new);
+
+ /** Called from the Config. */
+ protected EnumerableIEJoinRule(Config config) {
+ super(config);
+ }
+
+ @Override public @Nullable RelNode convert(RelNode rel) {
+ final Join join = (Join) rel;
+ if (join.getJoinType() != JoinRelType.INNER
+ || !join.getVariablesSet().isEmpty()
+ || !join.getSystemFieldList().isEmpty()) {
+ return null;
+ }
+
+ final int leftFieldCount = join.getLeft().getRowType().getFieldCount();
+ final List<RexNode> conjunctions =
+ RelOptUtil.conjunctions(join.getCondition());
+ if (conjunctions.size() < 2) {
+ return null;
+ }
+ for (int i = 0; i < conjunctions.size(); i++) {
+ final EnumerableIEJoin.Condition condition =
+ EnumerableIEJoin.analyzeConjunction(conjunctions.get(i),
leftFieldCount);
+ if (condition == null) {
+ return null;
+ }
+ if (i < 2
+ && !EnumerableIEJoin.supportsKeyTypes(
+ join.getLeft(), join.getRight(), condition)) {
+ return null;
+ }
+ }
+
+ final RelNode left = convert(join.getLeft(), join.getLeft().getTraitSet()
+ .replace(EnumerableConvention.INSTANCE));
+ final RelNode right = convert(join.getRight(),
join.getRight().getTraitSet()
+ .replace(EnumerableConvention.INSTANCE));
+ final RexBuilder rexBuilder = join.getCluster().getRexBuilder();
+ final RexNode ieCondition =
+ requireNonNull(RexUtil.composeConjunction(rexBuilder,
conjunctions.subList(0, 2)));
+ final EnumerableIEJoin ieJoin =
+ EnumerableIEJoin.create(left, right, ieCondition);
+ if (conjunctions.size() == 2) {
+ return ieJoin;
+ }
+
+ final RexNode residual =
+ requireNonNull(
+ RexUtil.composeConjunction(rexBuilder,
+ conjunctions.subList(2, conjunctions.size())));
+ final RexProgram program =
+ RexProgram.create(ieJoin.getRowType(),
+ rexBuilder.identityProjects(ieJoin.getRowType()), residual,
+ ieJoin.getRowType(), rexBuilder);
+ return EnumerableCalc.create(ieJoin, program);
+ }
+}
diff --git
a/core/src/main/java/org/apache/calcite/adapter/enumerable/EnumerableRules.java
b/core/src/main/java/org/apache/calcite/adapter/enumerable/EnumerableRules.java
index a470d3b7ce..5b20e665f4 100644
---
a/core/src/main/java/org/apache/calcite/adapter/enumerable/EnumerableRules.java
+++
b/core/src/main/java/org/apache/calcite/adapter/enumerable/EnumerableRules.java
@@ -61,6 +61,12 @@ private EnumerableRules() {
EnumerableMergeJoinRule.DEFAULT_CONFIG
.toRule(EnumerableMergeJoinRule.class);
+ /** Rule that converts a compatible
+ * {@link org.apache.calcite.rel.logical.LogicalJoin} to an
+ * {@link EnumerableIEJoin}. */
+ public static final RelOptRule ENUMERABLE_IE_JOIN_RULE =
+ EnumerableIEJoinRule.DEFAULT_CONFIG.toRule(EnumerableIEJoinRule.class);
+
public static final RelOptRule ENUMERABLE_CORRELATE_RULE =
EnumerableCorrelateRule.DEFAULT_CONFIG
.toRule(EnumerableCorrelateRule.class);
@@ -225,6 +231,7 @@ private EnumerableRules() {
ImmutableList.of(EnumerableRules.ENUMERABLE_JOIN_RULE,
EnumerableRules.ENUMERABLE_ASOFJOIN_RULE,
EnumerableRules.ENUMERABLE_MERGE_JOIN_RULE,
+ EnumerableRules.ENUMERABLE_IE_JOIN_RULE,
EnumerableRules.ENUMERABLE_CORRELATE_RULE,
EnumerableRules.ENUMERABLE_CONDITIONAL_CORRELATE_RULE,
EnumerableRules.ENUMERABLE_PROJECT_RULE,
diff --git a/core/src/main/java/org/apache/calcite/tools/Programs.java
b/core/src/main/java/org/apache/calcite/tools/Programs.java
index 1307e70100..27f6850be6 100644
--- a/core/src/main/java/org/apache/calcite/tools/Programs.java
+++ b/core/src/main/java/org/apache/calcite/tools/Programs.java
@@ -81,6 +81,7 @@ public class Programs {
EnumerableRules.ENUMERABLE_TABLE_SCAN_RULE,
EnumerableRules.ENUMERABLE_JOIN_RULE,
EnumerableRules.ENUMERABLE_MERGE_JOIN_RULE,
+ EnumerableRules.ENUMERABLE_IE_JOIN_RULE,
EnumerableRules.ENUMERABLE_CORRELATE_RULE,
EnumerableRules.ENUMERABLE_CONDITIONAL_CORRELATE_RULE,
EnumerableRules.ENUMERABLE_PROJECT_RULE,
diff --git a/core/src/main/java/org/apache/calcite/util/BuiltInMethod.java
b/core/src/main/java/org/apache/calcite/util/BuiltInMethod.java
index 731d420f34..a8c006d61e 100644
--- a/core/src/main/java/org/apache/calcite/util/BuiltInMethod.java
+++ b/core/src/main/java/org/apache/calcite/util/BuiltInMethod.java
@@ -51,6 +51,7 @@
import org.apache.calcite.linq4j.function.NullablePredicate2;
import org.apache.calcite.linq4j.function.Predicate1;
import org.apache.calcite.linq4j.function.Predicate2;
+import org.apache.calcite.linq4j.tree.ExpressionType;
import org.apache.calcite.linq4j.tree.FunctionExpression;
import org.apache.calcite.linq4j.tree.Primitive;
import org.apache.calcite.linq4j.tree.Types;
@@ -254,6 +255,10 @@ public enum BuiltInMethod {
MERGE_JOIN(EnumerableDefaults.class, "mergeJoin", Enumerable.class,
Enumerable.class, Function1.class, Function1.class, Predicate2.class,
Function2.class,
JoinType.class, Comparator.class, EqualityComparer.class),
+ IE_JOIN(EnumerableDefaults.class, "ieJoin", Enumerable.class,
+ Enumerable.class, Function1.class, Function1.class, Function1.class,
+ Function1.class, Comparator.class, Comparator.class,
+ ExpressionType.class, ExpressionType.class, Function2.class),
SLICE0(Enumerables.class, "slice0", Enumerable.class),
SEMI_JOIN(EnumerableDefaults.class, "semiJoin", Enumerable.class,
Enumerable.class, Function1.class, Function1.class,
diff --git
a/core/src/test/java/org/apache/calcite/test/enumerable/EnumerableIEJoinTest.java
b/core/src/test/java/org/apache/calcite/test/enumerable/EnumerableIEJoinTest.java
new file mode 100644
index 0000000000..42023ad051
--- /dev/null
+++
b/core/src/test/java/org/apache/calcite/test/enumerable/EnumerableIEJoinTest.java
@@ -0,0 +1,451 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to you under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.calcite.test.enumerable;
+
+import org.apache.calcite.adapter.enumerable.EnumerableIEJoin;
+import org.apache.calcite.adapter.enumerable.EnumerableRules;
+import org.apache.calcite.adapter.java.ReflectiveSchema;
+import org.apache.calcite.config.CalciteConnectionProperty;
+import org.apache.calcite.config.Lex;
+import org.apache.calcite.jdbc.JavaCollation;
+import org.apache.calcite.plan.RelOptPlanner;
+import org.apache.calcite.rel.RelNode;
+import org.apache.calcite.rel.RelRoot;
+import org.apache.calcite.rel.RelVisitor;
+import org.apache.calcite.rel.core.Join;
+import org.apache.calcite.rel.core.JoinRelType;
+import org.apache.calcite.rel.metadata.RelMetadataQuery;
+import org.apache.calcite.rel.type.RelDataType;
+import org.apache.calcite.runtime.Hook;
+import org.apache.calcite.sql.SqlCollation;
+import org.apache.calcite.sql.type.SqlTypeName;
+import org.apache.calcite.test.CalciteAssert;
+import org.apache.calcite.test.schemata.hr.Employee;
+import org.apache.calcite.test.schemata.hr.Event;
+import org.apache.calcite.test.schemata.hr.HrSchema;
+import org.apache.calcite.test.schemata.hr.HrSchemaBig;
+import org.apache.calcite.util.Holder;
+import org.apache.calcite.util.Util;
+
+import com.google.common.collect.ImmutableList;
+
+import org.checkerframework.checker.nullness.qual.Nullable;
+import org.junit.jupiter.api.Test;
+
+import java.sql.Timestamp;
+import java.text.Collator;
+import java.util.Locale;
+import java.util.function.Consumer;
+
+import static org.apache.calcite.linq4j.Nullness.castNonNull;
+
+import static org.hamcrest.CoreMatchers.allOf;
+import static org.hamcrest.CoreMatchers.containsString;
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.CoreMatchers.not;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import static java.util.Objects.requireNonNull;
+
+/** Unit tests for {@link EnumerableIEJoin}.
+ *
+ * <p>Disabling {@code ENUMERABLE_JOIN_RULE} forces execution through IEJoin
+ * for supported conditions, even on small inputs.
+ */
+class EnumerableIEJoinTest {
+ private static final SqlCollation PRIMARY_COLLATION =
+ new JavaCollation(SqlCollation.Coercibility.IMPLICIT, Locale.US,
+ Util.getDefaultCharset(), Collator.PRIMARY);
+
+ @Test void ieJoin() {
+ // The first comparison reverses the operands: rx < lx becomes lx > rx.
+ // Equal keys do not match these strict comparisons; null keys never match.
+ final Holder<@Nullable RelRoot> root = Holder.empty();
+ tester(new HrSchema())
+ .withRel(builder -> {
+ builder
+ .values(new String[]{"lx", "ly"},
+ 2, 8,
+ 5, 4,
+ null, 3)
+ .values(new String[]{"rx", "ry"},
+ 4, 3,
+ 7, 6,
+ 5, 4)
+ .join(JoinRelType.INNER,
+ builder.and(
+ builder.lessThan(
+ builder.field(2, 1, "rx"),
+ builder.field(2, 0, "lx")),
+ builder.greaterThan(
+ builder.field(2, 0, "ly"),
+ builder.field(2, 1, "ry"))));
+ return builder.build();
+ })
+ .withHook(Hook.PLANNER, (Consumer<RelOptPlanner>) planner ->
+ planner.removeRule(EnumerableRules.ENUMERABLE_JOIN_RULE))
+ .withHook(Hook.PLAN_BEFORE_IMPLEMENTATION,
+ (Consumer<RelRoot>) root::set)
+ .explainHookMatches(
+ "EnumerableIEJoin(condition=[AND(<($2, $0), >($1, $3))],
joinType=[inner])\n"
+ + " EnumerableValues(tuples=[[{ 2, 8 }, { 5, 4 }, { null, 3
}]])\n"
+ + " EnumerableValues(tuples=[[{ 4, 3 }, { 7, 6 }, { 5, 4
}]])\n")
+ .returnsUnordered("lx=5; ly=4; rx=4; ry=3");
+
+ final EnumerableIEJoin join =
+ (EnumerableIEJoin) requireNonNull(root.get()).rel;
+ assertThrows(IllegalArgumentException.class,
+ () -> join.copy(join.getTraitSet(), join.getCondition(),
+ join.getLeft(), join.getRight(), JoinRelType.LEFT, false));
+ assertThrows(IllegalArgumentException.class,
+ () -> join.copy(join.getTraitSet(),
+ join.getCluster().getRexBuilder().makeLiteral(true),
+ join.getLeft(), join.getRight(), JoinRelType.INNER, false));
+
+ // A missing row count on either input or the join leaves the cost unknown.
+ for (RelNode unknown : ImmutableList.of(join.getLeft(), join.getRight(),
join)) {
+ final RelMetadataQuery mq = new RelMetadataQuery() {
+ @Override public Double getRowCount(RelNode rel) {
+ if (rel == unknown) {
+ // The signature is not nullable yet; see CALCITE-4263.
+ return castNonNull(null);
+ }
+ return 10D;
+ }
+ };
+ assertNull(join.computeSelfCost(join.getCluster().getPlanner(), mq));
+ }
+ }
+
+ @Test void ieJoinDoesNotSupportApproximateNumbers() {
+ // Both signed zeros satisfy >= 0; NaN does not.
+ final Holder<@Nullable RelRoot> root = Holder.empty();
+ tester(new TestSchema())
+ .query("select l.name as left_name, r.name as right_name "
+ + "from lefts l join rights r "
+ + "on l.x >= r.x and l.y > r.y")
+ .withHook(Hook.PLAN_BEFORE_IMPLEMENTATION,
+ (Consumer<RelRoot>) root::set)
+ .explainHookMatches(
+ allOf(
+ containsString("EnumerableNestedLoopJoin"),
+ not(containsString("EnumerableIEJoin"))))
+ .returnsUnordered(
+ "left_name=minusZero; right_name=zero",
+ "left_name=plusZero; right_name=zero");
+ final Join join = findJoin(requireNonNull(root.get()).rel);
+ assertThat(
+ assertThrows(IllegalArgumentException.class,
+ () -> EnumerableIEJoin.create(
+ join.getLeft(), join.getRight(), join.getCondition()))
+ .getMessage(),
+ allOf(containsString("left JavaType(double)"),
+ containsString("right JavaType(double)")));
+ }
+
+ @Test void ieJoinDoesNotSupportAny() {
+ // ANY keys must be compared according to the values' runtime types.
+ final Holder<@Nullable RelNode> root = Holder.empty();
+ tester(new TestSchema())
+ .query("select l.name as left_name, r.name as right_name "
+ + "from anyLefts l join anyRights r "
+ + "on l.x < r.x and l.y > r.y")
+ .withHook(Hook.TRIMMED, (Consumer<RelNode>) root::set)
+ .explainContains("EnumerableNestedLoopJoin");
+ final Join join = findJoin(requireNonNull(root.get()));
+ assertThrows(IllegalArgumentException.class,
+ () -> EnumerableIEJoin.create(
+ join.getLeft(), join.getRight(), join.getCondition()));
+ }
+
+ @Test void ieJoinCostAndDefaultSelection() {
+ final HrSchemaBig schema = new HrSchemaBig();
+ // Count matching pairs independently of IEJoin.
+ int expectedCount = 0;
+ for (Employee left : schema.emps) {
+ for (Employee right : schema.emps) {
+ if (left.empid < right.empid && left.deptno >= right.deptno) {
+ expectedCount++;
+ }
+ }
+ }
+
+ // Keep all join rules enabled to test cost-based selection.
+ final Holder<@Nullable RelRoot> root = Holder.empty();
+ tester(schema)
+ .query("select count(*) from emps l join emps r "
+ + "on l.empid < r.empid and l.deptno >= r.deptno")
+ .withHook(Hook.PLAN_BEFORE_IMPLEMENTATION,
+ (Consumer<RelRoot>) root::set)
+ .explainHookMatches(containsString("EnumerableIEJoin"))
+ .returns("EXPR$0=" + expectedCount + "\n");
+
+ final Join join = findJoin(requireNonNull(root.get()).rel);
+ final EnumerableIEJoin selfJoin =
+ EnumerableIEJoin.create(join.getLeft(), join.getLeft(),
+ join.getCondition());
+ final RelMetadataQuery mq = new RelMetadataQuery() {
+ @Override public Double getRowCount(RelNode rel) {
+ return rel == selfJoin ? 3D : 10D;
+ }
+ };
+ assertThat(
+ requireNonNull(
+ selfJoin.computeSelfCost(selfJoin.getCluster().getPlanner(), mq))
+ .getRows(),
+ is(2D * Util.nLogN(20D) + 23D));
+ }
+
+ @Test void ieJoinWithBooleanAndDecimalKeys() {
+ // Boolean ordering is false < true.
+ tester(new HrSchema())
+ .query("select l.id as left_id, r.id as right_id "
+ + "from (values (1, true, cast(2 as decimal(5, 2))), "
+ + "(2, false, cast(4 as decimal(5, 2)))) as l(id, b, d) "
+ + "join (values (3, false, cast(3 as decimal(5, 2))), "
+ + "(4, true, cast(1 as decimal(5, 2)))) as r(id, b, d) "
+ + "on l.b > r.b and l.d < r.d")
+ .withHook(Hook.PLANNER, (Consumer<RelOptPlanner>) planner ->
+ planner.removeRule(EnumerableRules.ENUMERABLE_JOIN_RULE))
+ .explainHookMatches(containsString("EnumerableIEJoin"))
+ .returnsUnordered("left_id=1; right_id=3");
+ }
+
+ @Test void ieJoinWithBinaryAndNullableDateKeys() {
+ // A null date prevents a match even when the binary comparison succeeds.
+ tester(new HrSchema())
+ .query("select l.id as left_id, r.id as right_id "
+ + "from (values (1, cast(X'01' as varbinary(2)), date
'2020-01-02'), "
+ + "(2, cast(X'01' as varbinary(2)), cast(null as date))) as l(id,
b, d) "
+ + "join (values (3, cast(X'02' as varbinary(2)), date
'2020-01-01'), "
+ + "(4, cast(X'00' as varbinary(2)), date '2020-01-03')) "
+ + "as r(id, b, d) on l.b < r.b and l.d > r.d")
+ .withHook(Hook.PLANNER, (Consumer<RelOptPlanner>) planner ->
+ planner.removeRule(EnumerableRules.ENUMERABLE_JOIN_RULE))
+ .explainHookMatches(containsString("EnumerableIEJoin"))
+ .returnsUnordered("left_id=1; right_id=3");
+ }
+
+ @Test void ieJoinWithJavaTimestampKeys() {
+ // Java timestamps that differ only within a millisecond compare equal in
SQL.
+ tester(new TestSchema())
+ .query("select l.eventid from timestampLefts l join timestampRights r "
+ + "on l.ts > r.ts and l.eventid > r.eventid")
+ .withHook(Hook.PLANNER, (Consumer<RelOptPlanner>) planner ->
+ planner.removeRule(EnumerableRules.ENUMERABLE_JOIN_RULE))
+ .explainHookMatches(containsString("EnumerableIEJoin"))
+ .returnsUnordered("eventid=3");
+ }
+
+ @Test void ieJoinWithIntervalKeys() {
+ tester(new HrSchema())
+ .query("select l.id as left_id, r.id as right_id "
+ + "from (values (1, interval '2' day, interval '4' hour), "
+ + "(2, interval '4' day, interval '1' hour)) "
+ + "as l(id, d, h) "
+ + "join (values (3, interval '3' day, interval '2' hour), "
+ + "(4, interval '1' day, interval '5' hour)) "
+ + "as r(id, d, h) on l.d < r.d and l.h > r.h")
+ .withHook(Hook.PLANNER, (Consumer<RelOptPlanner>) planner ->
+ planner.removeRule(EnumerableRules.ENUMERABLE_JOIN_RULE))
+ .explainHookMatches(containsString("EnumerableIEJoin"))
+ .returnsUnordered("left_id=1; right_id=3");
+ }
+
+ @Test void ieJoinWithCollatedVarcharKeys() {
+ // PRIMARY collation ignores case and accents: "abc" and "ÀBC" compare
equal.
+ tester(new HrSchema())
+ .withRel(builder -> {
+ final RelDataType stringType =
+ builder.getTypeFactory().createTypeWithCharsetAndCollation(
+ builder.getTypeFactory().createSqlType(SqlTypeName.VARCHAR),
+ builder.getTypeFactory().getDefaultCharset(),
PRIMARY_COLLATION);
+ final RelDataType rowType = builder.getTypeFactory().builder()
+ .add("s", stringType)
+ .add("n", SqlTypeName.INTEGER)
+ .build();
+ return builder
+ .values(rowType, "abc", 2, "z", 1).as("l")
+ .values(rowType, "ÀBC", 1, "b", 3).as("r")
+ .join(JoinRelType.INNER,
+ builder.and(
+ builder.greaterThanOrEqual(
+ builder.field(2, "l", "s"),
+ builder.field(2, "r", "s")),
+ builder.greaterThan(
+ builder.field(2, "l", "n"),
+ builder.field(2, "r", "n"))))
+ .project(builder.field("l", "s"), builder.field("r", "s"))
+ .build();
+ })
+ .withHook(Hook.PLANNER, (Consumer<RelOptPlanner>) planner ->
+ planner.removeRule(EnumerableRules.ENUMERABLE_JOIN_RULE))
+ .explainHookMatches(containsString("EnumerableIEJoin"))
+ .returnsUnordered("s=abc; s0=ÀBC");
+ }
+
+ @Test void ieJoinWithApproximateRemainingPredicate() {
+ // EnumerableCalc evaluates the remaining z >= 0 comparison:
+ // both signed zeros match, but NaN does not.
+ tester(new TestSchema())
+ .query("select l.name as left_name, r.name as right_name "
+ + "from residualLefts l join residualRights r "
+ + "on l.x < r.x and l.y > r.y and l.z >= r.z")
+ .withHook(Hook.PLANNER, (Consumer<RelOptPlanner>) planner ->
+ planner.removeRule(EnumerableRules.ENUMERABLE_JOIN_RULE))
+ .explainHookMatches(
+ allOf(
+ containsString("EnumerableIEJoin"),
+ containsString(">=($t3, $t7)")))
+ .returnsUnordered(
+ "left_name=minusZero; right_name=zero",
+ "left_name=plusZero; right_name=zero");
+ }
+
+ @Test void ieJoinUnsupportedConditionsUseExistingRules() {
+ for (String condition : ImmutableList.of(
+ "e.empid < d.deptno",
+ "e.empid < d.deptno and e.deptno > d.deptno "
+ + "and e.empid = d.deptno",
+ "e.empid + 1 < d.deptno and e.deptno > d.deptno",
+ "e.empid < d.deptno or e.deptno > d.deptno",
+ "e.empid < e.deptno and e.deptno > d.deptno")) {
+ tester(new HrSchema())
+ .query("select * from emps e join depts d on " + condition)
+ .explainHookMatches(not(containsString("EnumerableIEJoin")))
+ .runs();
+ }
+ tester(new HrSchema())
+ .query("select * from emps e left join depts d "
+ + "on e.empid < d.deptno and e.deptno > d.deptno")
+ .explainHookMatches(not(containsString("EnumerableIEJoin")))
+ .runs();
+ }
+
+ @Test void ieJoinWithSharedField() {
+ // x = 2 matches both inclusive intervals.
+ tester(new HrSchema())
+ .query("select l.x, r.lo, r.hi "
+ + "from (values (1), (2), (3)) as l(x) "
+ + "join (values (1, 2), (2, 3)) as r(lo, hi) "
+ + "on l.x >= r.lo and l.x <= r.hi")
+ .withHook(Hook.PLANNER, (Consumer<RelOptPlanner>) planner ->
+ planner.removeRule(EnumerableRules.ENUMERABLE_JOIN_RULE))
+ .explainHookMatches(containsString("EnumerableIEJoin"))
+ .returnsUnordered("x=1; lo=1; hi=2", "x=2; lo=1; hi=2",
+ "x=2; lo=2; hi=3", "x=3; lo=2; hi=3");
+ }
+
+ private CalciteAssert.AssertThat tester(Object schema) {
+ return CalciteAssert.that()
+ .with(CalciteConnectionProperty.LEX, Lex.JAVA)
+ .with(CalciteConnectionProperty.FORCE_DECORRELATE, false)
+ .withSchema("s", new ReflectiveSchema(schema));
+ }
+
+ private static Join findJoin(RelNode rel) {
+ final Holder<@Nullable Join> join = Holder.empty();
+ new RelVisitor() {
+ @Override public void visit(RelNode node, int ordinal,
+ @Nullable RelNode parent) {
+ if (node instanceof Join) {
+ join.set((Join) node);
+ } else {
+ super.visit(node, ordinal, parent);
+ }
+ }
+ }.go(rel);
+ return requireNonNull(join.get());
+ }
+
+ /** Test schema for unsupported and residual key types. */
+ public static class TestSchema {
+ public final ApproximatePoint[] lefts = {
+ new ApproximatePoint("nan", Double.NaN, 1),
+ new ApproximatePoint("minusZero", -0D, 1),
+ new ApproximatePoint("plusZero", 0D, 1)
+ };
+ public final ApproximatePoint[] rights = {
+ new ApproximatePoint("zero", 0D, 0)
+ };
+ public final AnyPoint[] anyLefts = {
+ new AnyPoint("one", 1, 2)
+ };
+ public final AnyPoint[] anyRights = {
+ new AnyPoint("two", 2, 1)
+ };
+ public final ResidualPoint[] residualLefts = {
+ new ResidualPoint("nan", 2, 8, Double.NaN),
+ new ResidualPoint("minusZero", 2, 8, -0D),
+ new ResidualPoint("plusZero", 2, 8, 0D)
+ };
+ public final ResidualPoint[] residualRights = {
+ new ResidualPoint("zero", 4, 3, 0D)
+ };
+ public final Event[] timestampLefts = {
+ new Event(2, Timestamp.valueOf("2020-01-01 00:00:00.000002")),
+ new Event(3, Timestamp.valueOf("2020-01-01 00:00:00.001")),
+ new Event(4, null)
+ };
+ public final Event[] timestampRights = {
+ new Event(1, Timestamp.valueOf("2020-01-01 00:00:00.000001"))
+ };
+ }
+
+ /** Row with an approximate key. */
+ public static class ApproximatePoint {
+ public final String name;
+ public final double x;
+ public final int y;
+
+ ApproximatePoint(String name, double x, int y) {
+ this.name = name;
+ this.x = x;
+ this.y = y;
+ }
+ }
+
+ /** Row with dynamically typed keys. */
+ public static class AnyPoint {
+ public final String name;
+ public final Object x;
+ public final Object y;
+
+ AnyPoint(String name, Object x, Object y) {
+ this.name = name;
+ this.x = x;
+ this.y = y;
+ }
+ }
+
+ /** Row with an approximate residual key. */
+ public static class ResidualPoint {
+ public final String name;
+ public final int x;
+ public final int y;
+ public final double z;
+
+ ResidualPoint(String name, int x, int y, double z) {
+ this.name = name;
+ this.x = x;
+ this.y = y;
+ this.z = z;
+ }
+ }
+}
diff --git
a/core/src/test/resources/org/apache/calcite/test/CombineRelOptRulesTest.xml
b/core/src/test/resources/org/apache/calcite/test/CombineRelOptRulesTest.xml
index 1ae0496621..fc1ab908f8 100644
--- a/core/src/test/resources/org/apache/calcite/test/CombineRelOptRulesTest.xml
+++ b/core/src/test/resources/org/apache/calcite/test/CombineRelOptRulesTest.xml
@@ -110,7 +110,7 @@ EnumerableCombine
EnumerableTableScan(table=[[scott, DEPT]])
EnumerableTableScan(table=[[scott, EMP]])
EnumerableProject(ENAME=[$1], GRADE=[$8])
- EnumerableNestedLoopJoin(condition=[AND(>=($5, $9), <=($5, $10))],
joinType=[inner])
+ EnumerableIEJoin(condition=[AND(>=($5, $9), <=($5, $10))],
joinType=[inner])
EnumerableTableScan(table=[[scott, EMP]])
EnumerableTableScan(table=[[scott, SALGRADE]])
]]>
@@ -180,7 +180,7 @@ Combine
EnumerableCombine
EnumerableAggregate(group=[{9, 11}], EMP_COUNT=[COUNT()])
EnumerableTableSpool(readType=[LAZY], writeType=[LAZY], table=[[TEMP,
spool_0]])
- EnumerableNestedLoopJoin(condition=[AND(>=($5, $12), <=($5, $13))],
joinType=[inner])
+ EnumerableIEJoin(condition=[AND(>=($5, $12), <=($5, $13))],
joinType=[inner])
EnumerableProject(EMPNO=[$3], ENAME=[$4], JOB=[$5], MGR=[$6],
HIREDATE=[$7], SAL=[$8], COMM=[$9], DEPTNO=[$10], DEPTNO0=[$0], DNAME=[$1],
LOC=[$2])
EnumerableHashJoin(condition=[=($0, $10)], joinType=[inner])
EnumerableTableScan(table=[[scott, DEPT]])
diff --git
a/linq4j/src/main/java/org/apache/calcite/linq4j/EnumerableDefaults.java
b/linq4j/src/main/java/org/apache/calcite/linq4j/EnumerableDefaults.java
index 0eca4b6d52..2687c15131 100644
--- a/linq4j/src/main/java/org/apache/calcite/linq4j/EnumerableDefaults.java
+++ b/linq4j/src/main/java/org/apache/calcite/linq4j/EnumerableDefaults.java
@@ -34,6 +34,7 @@
import org.apache.calcite.linq4j.function.NullablePredicate2;
import org.apache.calcite.linq4j.function.Predicate1;
import org.apache.calcite.linq4j.function.Predicate2;
+import org.apache.calcite.linq4j.tree.ExpressionType;
import com.google.common.base.Suppliers;
import com.google.common.collect.HashMultiset;
@@ -2533,6 +2534,50 @@ private static <TSource, TInner, TKey>
Enumerable<TSource> semiEquiJoin_(
};
}
+ /**
+ * Joins pairs that satisfy two inequality predicates.
+ *
+ * <p>Each operator compares a left key with a right key using the
+ * corresponding comparator. Null keys do not match. Both inputs are
+ * materialized and sorted before the first result is returned.
+ *
+ * @throws IllegalArgumentException if either operator is not
+ * {@link ExpressionType#LessThan}, {@link
ExpressionType#LessThanOrEqual},
+ * {@link ExpressionType#GreaterThan}, or
+ * {@link ExpressionType#GreaterThanOrEqual}
+ */
+ public static <TLeft, TRight, TKey1, TKey2, TResult>
+ Enumerable<TResult> ieJoin(Enumerable<TLeft> left,
+ Enumerable<TRight> right,
+ Function1<? super TLeft, TKey1> leftKeySelector1,
+ Function1<? super TRight, TKey1> rightKeySelector1,
+ Function1<? super TLeft, TKey2> leftKeySelector2,
+ Function1<? super TRight, TKey2> rightKeySelector2,
+ Comparator<? super TKey1> comparator1,
+ Comparator<? super TKey2> comparator2,
+ ExpressionType operator1, ExpressionType operator2,
+ Function2<? super TLeft, ? super TRight, TResult> resultSelector) {
+ for (ExpressionType operator : Arrays.asList(operator1, operator2)) {
+ switch (operator) {
+ case LessThan:
+ case LessThanOrEqual:
+ case GreaterThan:
+ case GreaterThanOrEqual:
+ break;
+ default:
+ throw new IllegalArgumentException("Unsupported IEJoin operator: " +
operator);
+ }
+ }
+ return new AbstractEnumerable<TResult>() {
+ @Override public Enumerator<TResult> enumerator() {
+ return new IEJoinEnumerator<>(left, right,
+ leftKeySelector1, rightKeySelector1,
+ leftKeySelector2, rightKeySelector2,
+ comparator1, comparator2, operator1, operator2, resultSelector);
+ }
+ };
+ }
+
/**
* Correlates the elements of two sequences based on a predicate.
*/
diff --git
a/linq4j/src/main/java/org/apache/calcite/linq4j/IEJoinEnumerator.java
b/linq4j/src/main/java/org/apache/calcite/linq4j/IEJoinEnumerator.java
new file mode 100644
index 0000000000..9450d5960e
--- /dev/null
+++ b/linq4j/src/main/java/org/apache/calcite/linq4j/IEJoinEnumerator.java
@@ -0,0 +1,286 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to you under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.calcite.linq4j;
+
+import org.apache.calcite.linq4j.function.Function1;
+import org.apache.calcite.linq4j.function.Function2;
+import org.apache.calcite.linq4j.tree.ExpressionType;
+
+import org.checkerframework.checker.nullness.qual.Nullable;
+
+import java.util.ArrayList;
+import java.util.BitSet;
+import java.util.Comparator;
+import java.util.List;
+import java.util.NoSuchElementException;
+
+import static org.apache.calcite.linq4j.Nullness.castNonNull;
+
+/** Enumerator that implements IEJoin for two inequality predicates.
+ *
+ * <p>Both inputs are materialized; rows with a null key are discarded. The
+ * entries are sorted by each key. A right entry satisfies predicate 1 exactly
+ * when it follows a left entry in {@code firstOrder}, and predicate 2 exactly
+ * when it precedes that left entry in {@code secondOrder}. Sort directions and
+ * tie-breaking depend on the operators; see {@link #entryComparator}.
+ *
+ * <p>{@code permutation} maps positions in {@code secondOrder} to positions in
+ * {@code firstOrder}. Scanning in the second order, each right entry sets its
+ * first-order position in {@code activeRights}. For each left entry, set bits
+ * after its first-order position identify the right entries satisfying both
+ * predicates.
+ *
+ * <p>For example, for {@code l.x < r.x AND l.y > r.y}, let the left row be
+ * {@code l=(2,8)} and the right rows be {@code a=(4,3)}, {@code b=(1,6)}, and
+ * {@code c=(5,9)}:
+ *
+ * <pre>
+ * position: 0 1 2 3
+ * firstOrder (x asc): b l a c
+ * secondOrder (y asc): a b l c
+ * permutation: 2 0 1 3
+ * </pre>
+ *
+ * <p>When the scan reaches l, {@code activeRights} contains positions 0 (b)
+ * and 2 (a). Searching after l's position, 1, yields only a. Row b fails
+ * predicate 1, and row c has not yet been visited because it fails predicate
2.
+ *
+ * <p>Based on the union-array algorithm in Section 4.2 of Khayyat et al.,
+ * <a href="https://doi.org/10.14778/2831360.2831362">"Lightning Fast and Space
+ * Efficient Inequality Joins," PVLDB 8(13), 2015</a>.
+ *
+ * @param <TLeft> Left row type
+ * @param <TRight> Right row type
+ * @param <TKey1> First key type
+ * @param <TKey2> Second key type
+ * @param <TResult> Result row type
+ */
+final class IEJoinEnumerator<TLeft, TRight, TKey1, TKey2, TResult>
+ implements Enumerator<TResult> {
+ private final List<TLeft> leftRows = new ArrayList<>();
+
+ private final List<TRight> rightRows = new ArrayList<>();
+
+ /** Entries from both inputs in first-key order. */
+ private final List<Entry<TKey1, TKey2>> firstOrder;
+
+ /** Maps second-order positions to {@link #firstOrder} positions. */
+ private int[] permutation;
+
+ /** First-order positions of right entries already visited in the
+ * second-order scan. */
+ private final BitSet activeRights = new BitSet();
+
+ private final Function2<? super TLeft, ? super TRight, TResult>
resultSelector;
+
+ /** Next index in {@link #permutation} to visit. */
+ private int secondPosition;
+
+ /** Inclusive first-order position from which to resume searching for
+ * matches for {@link #currentLeft}. */
+ private int nextBit;
+
+ /** Left entry whose matches are being emitted, or null between entries. */
+ private @Nullable Entry<TKey1, TKey2> currentLeft;
+
+ private @Nullable TResult current;
+
+ /** Whether {@link #current} is a valid result. Kept separately because the
+ * result selector may itself return null. */
+ private boolean hasCurrent;
+
+ /** Reads and closes both input enumerators, caches the rows and their keys,
+ * and prepares both sort orders. Rows with either key null are excluded.
+ * {@link #reset()} reuses this state without reading the inputs again.
+ *
+ * <p>Selectors with the same numeric suffix form a predicate of the form
+ * {@code leftKey operator rightKey}, using the corresponding comparator.
+ * Comparators define key order; this class derives sort directions and
+ * equal-key tie-breaks from the operators. The result selector is called
+ * only as {@link #moveNext()} emits matching pairs.
+ */
+ IEJoinEnumerator(Enumerable<TLeft> left, Enumerable<TRight> right,
+ Function1<? super TLeft, TKey1> leftKeySelector1,
+ Function1<? super TRight, TKey1> rightKeySelector1,
+ Function1<? super TLeft, TKey2> leftKeySelector2,
+ Function1<? super TRight, TKey2> rightKeySelector2,
+ Comparator<? super TKey1> comparator1,
+ Comparator<? super TKey2> comparator2,
+ ExpressionType operator1, ExpressionType operator2,
+ Function2<? super TLeft, ? super TRight, TResult> resultSelector) {
+ this.resultSelector = resultSelector;
+
+ final List<Entry<TKey1, TKey2>> entries = new ArrayList<>();
+ try (Enumerator<TLeft> enumerator = left.enumerator()) {
+ while (enumerator.moveNext()) {
+ final TLeft row = enumerator.current();
+ final @Nullable TKey1 key1 = leftKeySelector1.apply(row);
+ final @Nullable TKey2 key2 = leftKeySelector2.apply(row);
+ if (key1 != null && key2 != null) {
+ final int rowIndex = leftRows.size();
+ leftRows.add(row);
+ entries.add(new Entry<>(true, rowIndex, key1, key2));
+ }
+ }
+ }
+ try (Enumerator<TRight> enumerator = right.enumerator()) {
+ while (enumerator.moveNext()) {
+ final TRight row = enumerator.current();
+ final @Nullable TKey1 key1 = rightKeySelector1.apply(row);
+ final @Nullable TKey2 key2 = rightKeySelector2.apply(row);
+ if (key1 != null && key2 != null) {
+ final int rowIndex = rightRows.size();
+ rightRows.add(row);
+ entries.add(new Entry<>(false, rowIndex, key1, key2));
+ }
+ }
+ }
+
+ firstOrder = new ArrayList<>(entries);
+ firstOrder.sort(
+ entryComparator(comparator1, operator1, true));
+ for (int i = 0; i < firstOrder.size(); i++) {
+ firstOrder.get(i).firstPosition = i;
+ }
+
+ final List<Entry<TKey1, TKey2>> secondOrder = new ArrayList<>(entries);
+ secondOrder.sort(
+ entryComparator(comparator2, operator2, false));
+ permutation = new int[secondOrder.size()];
+ for (int i = 0; i < secondOrder.size(); i++) {
+ permutation[i] = secondOrder.get(i).firstPosition;
+ }
+ }
+
+ /** Orders entries by key 1 if {@code isFirstOrder}, or by key 2 otherwise.
+ *
+ * <p>For equal keys from different inputs, strict operators put the right
+ * entry first in the first order and last in the second order, excluding the
+ * pair. Non-strict operators reverse both tie-breaks to include the pair.
+ */
+ @SuppressWarnings("unchecked")
+ private static <TKey1, TKey2, TKey> Comparator<Entry<TKey1, TKey2>>
+ entryComparator(Comparator<? super TKey> comparator,
+ ExpressionType operator, boolean isFirstOrder) {
+ final boolean greaterThan =
+ operator == ExpressionType.GreaterThan
+ || operator == ExpressionType.GreaterThanOrEqual;
+ final boolean descending =
+ isFirstOrder ? greaterThan : !greaterThan;
+ final boolean strict =
+ operator == ExpressionType.LessThan
+ || operator == ExpressionType.GreaterThan;
+ final boolean leftSideFirst = isFirstOrder != strict;
+ return (entry1, entry2) -> {
+ final TKey key1 =
+ castNonNull((TKey) (isFirstOrder ? entry1.key1 : entry1.key2));
+ final TKey key2 =
+ castNonNull((TKey) (isFirstOrder ? entry2.key1 : entry2.key2));
+ final int c = descending
+ ? comparator.compare(key2, key1)
+ : comparator.compare(key1, key2);
+ if (c != 0 || entry1.isLeft == entry2.isLeft) {
+ return c;
+ }
+
+ return entry1.isLeft == leftSideFirst ? -1 : 1;
+ };
+ }
+
+ @Override public TResult current() {
+ if (!hasCurrent) {
+ throw new NoSuchElementException();
+ }
+ return castNonNull(current);
+ }
+
+ @Override public boolean moveNext() {
+ hasCurrent = false;
+ while (true) {
+ // Rights already seen in secondOrder satisfy predicate 2; active bits
+ // after currentLeft's position in firstOrder also satisfy predicate 1.
+ if (currentLeft != null) {
+ final int bit = activeRights.nextSetBit(nextBit);
+ if (bit >= 0) {
+ nextBit = bit + 1;
+ final Entry<TKey1, TKey2> right = firstOrder.get(bit);
+ current =
+ resultSelector.apply(leftRows.get(currentLeft.rowIndex),
+ rightRows.get(right.rowIndex));
+ hasCurrent = true;
+ return true;
+ }
+ currentLeft = null;
+ }
+
+ if (secondPosition >= permutation.length) {
+ current = null;
+ return false;
+ }
+
+ final int firstPosition = permutation[secondPosition++];
+ final Entry<TKey1, TKey2> entry = firstOrder.get(firstPosition);
+ if (entry.isLeft) {
+ currentLeft = entry;
+ nextBit = firstPosition + 1;
+ } else {
+ activeRights.set(firstPosition);
+ }
+ }
+ }
+
+ @Override public void reset() {
+ activeRights.clear();
+ secondPosition = 0;
+ nextBit = 0;
+ currentLeft = null;
+ current = null;
+ hasCurrent = false;
+ }
+
+ @Override public void close() {
+ reset();
+ leftRows.clear();
+ rightRows.clear();
+ firstOrder.clear();
+ permutation = new int[0];
+ }
+
+ /** Row entry shared by the two sorted orders.
+ *
+ * @param <TKey1> First key type
+ * @param <TKey2> Second key type
+ */
+ private static final class Entry<TKey1, TKey2> {
+ final boolean isLeft;
+
+ /** Index into {@code leftRows} or {@code rightRows}, according to
+ * {@link #isLeft}. */
+ final int rowIndex;
+ final TKey1 key1;
+ final TKey2 key2;
+ /** Index in {@code firstOrder}, assigned after sorting. */
+ int firstPosition;
+
+ private Entry(boolean isLeft, int rowIndex, TKey1 key1, TKey2 key2) {
+ this.isLeft = isLeft;
+ this.rowIndex = rowIndex;
+ this.key1 = key1;
+ this.key2 = key2;
+ }
+ }
+}
diff --git
a/linq4j/src/test/java/org/apache/calcite/linq4j/test/IEJoinTest.java
b/linq4j/src/test/java/org/apache/calcite/linq4j/test/IEJoinTest.java
new file mode 100644
index 0000000000..eaabc407ad
--- /dev/null
+++ b/linq4j/src/test/java/org/apache/calcite/linq4j/test/IEJoinTest.java
@@ -0,0 +1,245 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to you under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.calcite.linq4j.test;
+
+import org.apache.calcite.linq4j.Enumerable;
+import org.apache.calcite.linq4j.EnumerableDefaults;
+import org.apache.calcite.linq4j.Enumerator;
+import org.apache.calcite.linq4j.Linq4j;
+import org.apache.calcite.linq4j.tree.ExpressionType;
+
+import org.checkerframework.checker.nullness.qual.Nullable;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+import java.util.NoSuchElementException;
+import java.util.Random;
+
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/** Tests for IEJoin. */
+class IEJoinTest {
+ private static final List<ExpressionType> OPERATORS =
+ Arrays.asList(ExpressionType.LessThan, ExpressionType.LessThanOrEqual,
+ ExpressionType.GreaterThan, ExpressionType.GreaterThanOrEqual);
+
+ private static final List<Point> LEFT =
+ Arrays.asList(new Point("l0", 2, 8),
+ new Point("l1", 5, 4),
+ new Point("l2", 5, 4),
+ new Point("lnx", null, 3),
+ new Point("lny", 3, null));
+
+ private static final List<Point> RIGHT =
+ Arrays.asList(new Point("r0", 4, 3),
+ new Point("r1", 7, 6),
+ new Point("r2", 5, 4),
+ new Point("rnx", null, 2),
+ new Point("rny", 6, null));
+
+ @Test void testAllOperatorCombinationsAgainstNestedLoop() {
+ for (ExpressionType operator1 : OPERATORS) {
+ for (ExpressionType operator2 : OPERATORS) {
+ assertMatches("fixed input", LEFT, RIGHT, operator1, operator2);
+ }
+ }
+ }
+
+ @Test void testRandomInputsAgainstNestedLoop() {
+ final Random random = new Random(0);
+ for (int trial = 0; trial < 200; trial++) {
+ final List<Point> left = new ArrayList<>();
+ final List<Point> right = new ArrayList<>();
+ final int leftCount = random.nextInt(9);
+ final int rightCount = random.nextInt(9);
+ for (int i = 0; i < leftCount; i++) {
+ left.add(
+ new Point("l" + i,
+ random.nextInt(5) == 0 ? null : random.nextInt(7) - 3,
+ random.nextInt(5) == 0 ? null : random.nextInt(7) - 3));
+ }
+ for (int i = 0; i < rightCount; i++) {
+ right.add(
+ new Point("r" + i,
+ random.nextInt(5) == 0 ? null : random.nextInt(7) - 3,
+ random.nextInt(5) == 0 ? null : random.nextInt(7) - 3));
+ }
+ for (ExpressionType operator1 : OPERATORS) {
+ for (ExpressionType operator2 : OPERATORS) {
+ assertMatches("trial " + trial, left, right, operator1, operator2);
+ }
+ }
+ }
+ }
+
+ @Test void testEmptyAndSameInputs() {
+ assertMatches("empty left", Collections.emptyList(), RIGHT,
+ ExpressionType.LessThan, ExpressionType.GreaterThan);
+ assertMatches("empty right", LEFT, Collections.emptyList(),
+ ExpressionType.LessThan, ExpressionType.GreaterThan);
+ assertMatches("same input", LEFT, LEFT,
+ ExpressionType.LessThanOrEqual,
+ ExpressionType.GreaterThanOrEqual);
+ }
+
+ @Test void testSameKeyForBothPredicates() {
+ for (ExpressionType operator1 : OPERATORS) {
+ for (ExpressionType operator2 : OPERATORS) {
+ final List<String> expected = new ArrayList<>();
+ for (Point left : LEFT) {
+ for (Point right : RIGHT) {
+ if (test(left.x, right.x, operator1)
+ && test(left.x, right.x, operator2)) {
+ expected.add(left.name + ":" + right.name);
+ }
+ }
+ }
+ final List<String> actual =
+ EnumerableDefaults.ieJoin(
+ Linq4j.asEnumerable(LEFT), Linq4j.asEnumerable(RIGHT),
+ point -> point.x, point -> point.x,
+ point -> point.x, point -> point.x,
+ Comparator.naturalOrder(), Comparator.naturalOrder(),
+ operator1, operator2,
+ (left, right) -> left.name + ":" + right.name).toList();
+ Collections.sort(expected);
+ Collections.sort(actual);
+ assertThat(operator1 + "/" + operator2, actual, is(expected));
+ }
+ }
+ }
+
+ @Test void testUnsupportedOperators() {
+ for (ExpressionType operator : ExpressionType.values()) {
+ if (OPERATORS.contains(operator)) {
+ continue;
+ }
+ assertThrows(IllegalArgumentException.class,
+ () -> ieJoin(Collections.emptyList(), Collections.emptyList(),
+ operator, ExpressionType.LessThan));
+ assertThrows(IllegalArgumentException.class,
+ () -> ieJoin(Collections.emptyList(), Collections.emptyList(),
+ ExpressionType.LessThan, operator));
+ }
+ }
+
+ @Test void testReset() {
+ final Enumerable<String> join =
+ ieJoin(LEFT, RIGHT, ExpressionType.LessThan,
+ ExpressionType.GreaterThan);
+ final List<String> first = new ArrayList<>();
+ final List<String> second = new ArrayList<>();
+ try (Enumerator<String> enumerator = join.enumerator()) {
+ assertThrows(NoSuchElementException.class, enumerator::current);
+ while (enumerator.moveNext()) {
+ first.add(enumerator.current());
+ }
+ assertThrows(NoSuchElementException.class, enumerator::current);
+ enumerator.reset();
+ assertThrows(NoSuchElementException.class, enumerator::current);
+ while (enumerator.moveNext()) {
+ second.add(enumerator.current());
+ }
+ }
+ assertThat(second, is(first));
+ assertThat(join.toList(), is(first));
+ }
+
+ private static void assertMatches(String context, List<Point> left,
+ List<Point> right, ExpressionType operator1,
+ ExpressionType operator2) {
+ final List<String> expected = nestedLoop(left, right, operator1,
operator2);
+ final List<String> actual = ieJoin(left, right, operator1,
operator2).toList();
+ Collections.sort(actual);
+ assertThat(context + ", " + operator1 + "/" + operator2
+ + ", left=" + left + ", right=" + right,
+ actual, is(expected));
+ }
+
+ /** Computes the actual row pairs using the IEJoin implementation under
test. */
+ private static Enumerable<String> ieJoin(List<Point> left, List<Point> right,
+ ExpressionType operator1, ExpressionType operator2) {
+ return EnumerableDefaults.ieJoin(
+ Linq4j.asEnumerable(left), Linq4j.asEnumerable(right),
+ point -> point.x, point -> point.x,
+ point -> point.y, point -> point.y,
+ Comparator.naturalOrder(), Comparator.naturalOrder(),
+ operator1, operator2,
+ (leftPoint, rightPoint) -> leftPoint.name + ":" + rightPoint.name);
+ }
+
+ /** Computes the expected row pairs independently using nested loops and
+ * direct comparisons. Null keys do not match. Sorts the result for
comparison
+ * with the IEJoin result. */
+ private static List<String> nestedLoop(List<Point> leftRows,
+ List<Point> rightRows, ExpressionType operator1,
+ ExpressionType operator2) {
+ final List<String> result = new ArrayList<>();
+ for (Point left : leftRows) {
+ for (Point right : rightRows) {
+ if (test(left.x, right.x, operator1)
+ && test(left.y, right.y, operator2)) {
+ result.add(left.name + ":" + right.name);
+ }
+ }
+ }
+ Collections.sort(result);
+ return result;
+ }
+
+ private static boolean test(@Nullable Integer left, @Nullable Integer right,
+ ExpressionType operator) {
+ if (left == null || right == null) {
+ return false;
+ }
+ switch (operator) {
+ case LessThan:
+ return left < right;
+ case LessThanOrEqual:
+ return left <= right;
+ case GreaterThan:
+ return left > right;
+ case GreaterThanOrEqual:
+ return left >= right;
+ default:
+ throw new AssertionError(operator);
+ }
+ }
+
+ /** Test row. */
+ private static final class Point {
+ final String name;
+ final @Nullable Integer x;
+ final @Nullable Integer y;
+
+ private Point(String name, @Nullable Integer x, @Nullable Integer y) {
+ this.name = name;
+ this.x = x;
+ this.y = y;
+ }
+
+ @Override public String toString() {
+ return name + "(" + x + ", " + y + ")";
+ }
+ }
+}