This is an automated email from the ASF dual-hosted git repository.
amashenkov pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/ignite-3.git
The following commit(s) were added to refs/heads/main by this push:
new 97bb0b78dea IGNITE-26014 Sql. ArrayIndexOutOfBoundsException when
converting MERGE with join (#6335)
97bb0b78dea is described below
commit 97bb0b78dea9794ae7b6fe02539ed09d15f91618
Author: Andrew V. Mashenkov <[email protected]>
AuthorDate: Fri Aug 1 14:47:50 2025 +0300
IGNITE-26014 Sql. ArrayIndexOutOfBoundsException when converting MERGE with
join (#6335)
---
.../engine/prepare/IgniteSqlToRelConvertor.java | 42 +++++++++-
.../sql/engine/planner/DmlPlannerTest.java | 94 +++++++++++++++++++++-
2 files changed, 134 insertions(+), 2 deletions(-)
diff --git
a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/prepare/IgniteSqlToRelConvertor.java
b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/prepare/IgniteSqlToRelConvertor.java
index 0220e52aa28..adb6012cf4b 100644
---
a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/prepare/IgniteSqlToRelConvertor.java
+++
b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/prepare/IgniteSqlToRelConvertor.java
@@ -233,6 +233,7 @@ public class IgniteSqlToRelConvertor extends
SqlToRelConverter implements Initia
int numLevel1Exprs = 0;
List<RexNode> level1InsertExprs = null;
List<RexNode> level2InsertExprs = null;
+ boolean needRepairProject = false;
if (insertCall != null) {
RelNode insertRel = convertInsert(insertCall);
@@ -264,11 +265,19 @@ public class IgniteSqlToRelConvertor extends
SqlToRelConverter implements Initia
if (!input.getInputs().isEmpty() && input.getInput(0) instanceof
LogicalProject) {
level2InsertExprs = ((LogicalProject)
input.getInput(0)).getProjects();
}
+
+ // If source rel contains project, then we expect at least 3
nested projects,
+ // otherwise it means source rel project was merged unexpectedly
and project must be repaired.
+ needRepairProject = ((LogicalJoin)
mergeSourceRel.getInput(0)).getLeft() instanceof LogicalProject
+ && (input.getInputs().isEmpty()
+ || !(input.getInput(0) instanceof LogicalProject)
+ || input.getInput(0).getInputs().isEmpty()
+ || !(input.getInput(0).getInput(0) instanceof
LogicalProject));
}
LogicalJoin join = (LogicalJoin) mergeSourceRel.getInput(0);
- final List<RexNode> projects = new ArrayList<>();
+ List<RexNode> projects = new ArrayList<>();
for (int level1Idx = 0; level1Idx < numLevel1Exprs; level1Idx++) {
requireNonNull(level1InsertExprs, "level1InsertExprs");
@@ -281,6 +290,14 @@ public class IgniteSqlToRelConvertor extends
SqlToRelConverter implements Initia
projects.add(level1InsertExprs.get(level1Idx));
}
}
+
+ // It is possible the method `convertInsert` merge projections (e.g.
due to RelBuilder.Config.withBloat)
+ // In that case, we should recover project on top of source project
(mergeSourceRel left branch) to get correct input refs.
+ // Most likely, we should disable bloat, but the `relBuilder` is out
of our control, due parent private field visibility.
+ if (needRepairProject) {
+ projects = repairProject(join, projects);
+ }
+
if (updateCall != null) {
final LogicalProject project = (LogicalProject) mergeSourceRel;
projects.addAll(project.getProjects());
@@ -301,6 +318,29 @@ public class IgniteSqlToRelConvertor extends
SqlToRelConverter implements Initia
targetColumnNameList, null, false);
}
+ /**
+ * This is a dirty hack to fix merged InsertCall projection.
+ */
+ private List<RexNode> repairProject(LogicalJoin join, List<RexNode>
actual) {
+ if (!(join.getLeft() instanceof LogicalProject)) {
+ return actual;
+ }
+
+ List<RexNode> original = ((LogicalProject)
join.getLeft()).getProjects();
+
+ ArrayList<RexNode> recovered = new ArrayList<>(actual.size());
+ for (RexNode rexNode : actual) {
+ int index = original.indexOf(rexNode);
+ if (index == -1) {
+ recovered.add(rexNode);
+ } else {
+ recovered.add(rexBuilder.makeInputRef(rexNode.getType(),
index));
+ }
+ }
+
+ return recovered;
+ }
+
@Override
public RelOptTable getTargetTable(SqlNode call) {
return super.getTargetTable(call);
diff --git
a/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/planner/DmlPlannerTest.java
b/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/planner/DmlPlannerTest.java
index 0e4f16c5b32..8aded72edbe 100644
---
a/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/planner/DmlPlannerTest.java
+++
b/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/planner/DmlPlannerTest.java
@@ -18,17 +18,21 @@
package org.apache.ignite.internal.sql.engine.planner;
import static org.apache.ignite.internal.sql.engine.util.Commons.cast;
+import static org.junit.jupiter.api.Assertions.assertEquals;
import java.util.List;
import java.util.UUID;
import java.util.function.Predicate;
import java.util.stream.Stream;
import org.apache.calcite.rel.type.RelDataType;
+import org.apache.calcite.rex.RexInputRef;
+import org.apache.calcite.rex.RexLiteral;
import org.apache.calcite.rex.RexNode;
import org.apache.calcite.sql.validate.SqlValidatorException;
import org.apache.ignite.internal.sql.engine.framework.TestBuilders;
import org.apache.ignite.internal.sql.engine.rel.IgniteExchange;
import org.apache.ignite.internal.sql.engine.rel.IgniteKeyValueModify;
+import org.apache.ignite.internal.sql.engine.rel.IgniteMergeJoin;
import org.apache.ignite.internal.sql.engine.rel.IgniteProject;
import org.apache.ignite.internal.sql.engine.rel.IgniteTableModify;
import org.apache.ignite.internal.sql.engine.rel.IgniteTableScan;
@@ -263,7 +267,7 @@ public class DmlPlannerTest extends AbstractPlannerTest {
IgniteSchema schema = createSchema(test);
IgniteTestUtils.assertThrowsWithCause(
- () -> physicalPlan(query, schema),
+ () -> physicalPlan(query, schema),
SqlValidatorException.class,
"Primary key columns are not modifiable"
);
@@ -316,6 +320,94 @@ public class DmlPlannerTest extends AbstractPlannerTest {
);
}
+ @Test
+ public void testMergeWithSubquery() throws Exception {
+ IgniteTable test1 = TestBuilders.table()
+ .name("T1")
+ .addKeyColumn("ID", NativeTypes.INT32)
+ .addColumn("VAL1", NativeTypes.INT32)
+ .addColumn("VAL2", NativeTypes.INT32)
+ .distribution(IgniteDistributions.single())
+ .build();
+
+ IgniteTable test2 = TestBuilders.table()
+ .name("T2")
+ .addKeyColumn("ID", NativeTypes.INT32)
+ .addColumn("VAL1", NativeTypes.INT32)
+ .addColumn("VAL2", NativeTypes.INT32)
+ .addColumn("VAL3", NativeTypes.INT32)
+ .addColumn("VAL4", NativeTypes.INT32)
+ .addColumn("VAL5", NativeTypes.INT32)
+ .distribution(IgniteDistributions.single())
+ .build();
+
+ IgniteSchema schema = createSchema(test1, test2);
+
+ assertPlan(
+ "MERGE INTO t1 dst\n"
+ + " USING (\n"
+ + " SELECT t1.id, t2.val5\n"
+ + " FROM t1 LEFT JOIN t2 ON t1.id = t2.id\n"
+ + " ) src\n"
+ + " ON src.id = dst.id\n"
+ + " WHEN MATCHED THEN UPDATE SET val1 = src.val5\n"
+ + " WHEN NOT MATCHED THEN INSERT (id, val1) VALUES
(src.id, src.val5)",
+ schema,
+ isInstanceOf(IgniteTableModify.class)
+
.and(hasChildThat(isInstanceOf(IgniteProject.class).and(
+ expectedProject(
+ p -> p instanceof RexInputRef &&
((RexInputRef) p).getIndex() == 3,
+ p -> p instanceof RexInputRef &&
((RexInputRef) p).getIndex() == 4,
+ p -> p instanceof RexLiteral &&
((RexLiteral) p).isNull(),
+ p -> p instanceof RexInputRef &&
((RexInputRef) p).getIndex() == 0,
+ p -> p instanceof RexInputRef &&
((RexInputRef) p).getIndex() == 1,
+ p -> p instanceof RexInputRef &&
((RexInputRef) p).getIndex() == 2,
+ p -> p instanceof RexInputRef &&
((RexInputRef) p).getIndex() == 4
+ ))))
+ .and(hasChildThat(isInstanceOf(IgniteMergeJoin.class)
+ .and(m ->
m.getRowType().getFieldNames().equals(List.of("ID", "VAL1", "VAL2", "ID$0",
"VAL5")))
+ )));
+
+ assertPlan(
+ "MERGE INTO t1 dst\n"
+ + " USING (\n"
+ + " SELECT t1.id, t2.val5\n"
+ + " FROM t1 LEFT JOIN t2 ON t1.id = t2.id\n"
+ + " ) src\n"
+ + " ON src.id = dst.id\n"
+ + " WHEN MATCHED THEN UPDATE SET val1 = src.val5\n"
+ + " WHEN NOT MATCHED THEN INSERT (id, val2) VALUES
(src.id, src.val5)",
+ schema,
+ isInstanceOf(IgniteTableModify.class)
+
.and(hasChildThat(isInstanceOf(IgniteProject.class).and(
+ expectedProject(
+ p -> p instanceof RexInputRef &&
((RexInputRef) p).getIndex() == 3,
+ p -> p instanceof RexLiteral &&
((RexLiteral) p).isNull(),
+ p -> p instanceof RexInputRef &&
((RexInputRef) p).getIndex() == 4,
+ p -> p instanceof RexInputRef &&
((RexInputRef) p).getIndex() == 0,
+ p -> p instanceof RexInputRef &&
((RexInputRef) p).getIndex() == 1,
+ p -> p instanceof RexInputRef &&
((RexInputRef) p).getIndex() == 2,
+ p -> p instanceof RexInputRef &&
((RexInputRef) p).getIndex() == 4
+ ))))
+ .and(hasChildThat(isInstanceOf(IgniteMergeJoin.class)
+ .and(m ->
m.getRowType().getFieldNames().equals(List.of("ID", "VAL1", "VAL2", "ID$0",
"VAL5")))
+ )));
+ }
+
+ @SafeVarargs
+ private static Predicate<IgniteProject>
expectedProject(Predicate<RexNode>... predicates) {
+ return p -> {
+ int i = 0;
+ assertEquals(p.getProjects().size(), predicates.length);
+ for (RexNode project : p.getProjects()) {
+ if (!predicates[i++].test(project)) {
+ return false;
+ }
+ }
+ return true;
+ };
+ }
+
private static Stream<String> updatePrimaryKey() {
return Stream.of(
"UPDATE TEST SET ID = ID + 1",