diff --git a/engine/src/main/java/com/arcadedb/query/opencypher/executor/CypherExecutionPlan.java b/engine/src/main/java/com/arcadedb/query/opencypher/executor/CypherExecutionPlan.java index 5204db8967..6be159364d 100644 --- a/engine/src/main/java/com/arcadedb/query/opencypher/executor/CypherExecutionPlan.java +++ b/engine/src/main/java/com/arcadedb/query/opencypher/executor/CypherExecutionPlan.java @@ -1664,7 +1664,7 @@ private AbstractExecutionStep buildMatchStep(final MatchClause matchClause, Abst AbstractExecutionStep nextStep; if (relPattern.isVariableLength()) { nextStep = new ExpandPathStep(effectiveSourceVar, pathVariable, relVar, effectiveTargetVar, relPattern, - true, effectiveTargetNode, pathPattern.getEffectivePathMode(), new HashSet<>(boundVariables), context); + true, effectiveTargetNode, pathPattern.getEffectivePathMode(), computePrevVarsForVlp(pathPattern, i, boundVariables), context); } else { // Check if this hop requires IN traversal on a unidirectional edge. // Unidirectional edges don't store incoming links, so we must restructure: @@ -2028,7 +2028,7 @@ public String prettyPrint(final int depth, final int indent) { // Variable-length path - pass path variable, relationship variable, and target node for label // filtering. Snapshot previously bound variables for relationship-uniqueness scoping. nextStep = new ExpandPathStep(currentSourceVar, pathVariable, relVar, targetVar, relPattern, true, - targetNode, pathPattern.getEffectivePathMode(), new HashSet<>(legacyBoundVariables), context); + targetNode, pathPattern.getEffectivePathMode(), computePrevVarsForVlp(pathPattern, i, legacyBoundVariables), context); } else { // Fixed-length relationship - pass path variable, target node pattern, and bound variables nextStep = new MatchRelationshipStep(currentSourceVar, relVar, targetVar, relPattern, pathVariable, @@ -3511,6 +3511,29 @@ private boolean[] computeHopEdgeTrackingNeeds(final PathPattern pathPattern) { return needs; } + /** + * Computes the set of "previously bound" variables to pass to {@link ExpandPathStep} for a + * variable-length hop at {@code vlpHopIndex} within {@code pathPattern}. + *

+ * OpenCypher path isomorphism applies within a single path, not within a MATCH clause. + * A relationship variable bound by a prior MATCH that is also explicitly named in the current + * path pattern is a same-path co-participant and must be checked for edge conflicts even though + * it was introduced before this MATCH. We therefore remove those co-participants from the + * exclusion set before handing it to ExpandPathStep. + */ + private static Set computePrevVarsForVlp(final PathPattern pathPattern, final int vlpHopIndex, + final Set boundVariables) { + final Set prevVars = new HashSet<>(boundVariables); + for (int j = 0; j < pathPattern.getRelationshipCount(); j++) { + if (j == vlpHopIndex) + continue; + final RelationshipPattern rel = pathPattern.getRelationship(j); + if (rel.getVariable() != null && !rel.getVariable().isEmpty()) + prevVars.remove(rel.getVariable()); + } + return prevVars; + } + /** * Checks if two hops with the same edge type are guaranteed to match different physical edges * based on the vertex labels at their edge endpoints. diff --git a/engine/src/test/java/com/arcadedb/query/opencypher/Issue4006BoundRelVarPathIsomorphismTest.java b/engine/src/test/java/com/arcadedb/query/opencypher/Issue4006BoundRelVarPathIsomorphismTest.java new file mode 100644 index 0000000000..1b3f50fb39 --- /dev/null +++ b/engine/src/test/java/com/arcadedb/query/opencypher/Issue4006BoundRelVarPathIsomorphismTest.java @@ -0,0 +1,104 @@ +/* + * Copyright © 2021-present Arcade Data Ltd (info@arcadedata.com) + * + * Licensed 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. + * + * SPDX-FileCopyrightText: 2021-present Arcade Data Ltd (info@arcadedata.com) + * SPDX-License-Identifier: Apache-2.0 + */ +package com.arcadedb.query.opencypher; + +import com.arcadedb.database.Database; +import com.arcadedb.database.DatabaseFactory; +import com.arcadedb.query.sql.executor.Result; +import com.arcadedb.query.sql.executor.ResultSet; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Regression test for GitHub issue #4006. + *

+ * When a relationship variable {@code r} is bound in one MATCH clause and then referenced + * explicitly inside a path pattern containing variable-length segments in a subsequent MATCH, + * the variable-length segments must not re-traverse {@code r}. OpenCypher path isomorphism + * applies within a single path, not within a single MATCH clause. + *

+ * This corresponds to TCK scenario {@code Match4 [7]}: + * "Matching variable length patterns including a bound relationship". + */ +class Issue4006BoundRelVarPathIsomorphismTest { + private Database database; + + @BeforeEach + void setUp() { + database = new DatabaseFactory("./target/databases/testopencypher-4006").create(); + database.getSchema().createVertexType("Node4006"); + database.getSchema().createEdgeType("EDGE4006"); + database.transaction(() -> database.command("opencypher", + "CREATE (n0:Node4006)-[:EDGE4006]->(n1:Node4006)-[:EDGE4006]->(n2:Node4006)-[:EDGE4006]->(n3:Node4006)")); + } + + @AfterEach + void tearDown() { + if (database != null) { + database.drop(); + database = null; + } + } + + /** + * TCK Match4 [7]: variable-length segments flanking a bound relationship must obey path + * isomorphism - they must not re-traverse the explicitly named {@code r}. + */ + @Test + void vlpSegmentsDoNotReuseExplicitlyNamedBoundRelationship() { + final ResultSet result = database.query("opencypher", + "MATCH ()-[r:EDGE4006]-()" + + " MATCH p = (n)-[*0..1]-()-[r]-()-[*0..1]-(m)" + + " RETURN count(p) AS c"); + + final List rows = collect(result); + assertThat(rows).hasSize(1); + assertThat(((Number) rows.get(0).getProperty("c")).longValue()).isEqualTo(32L); + } + + /** + * Regression guard for issue #3999: a previously bound relationship that is NOT part + * of the current path pattern must not block the variable-length traversal. + */ + @Test + void unboundedVlpIsNotBlockedByUnrelatedPreviouslyBoundRel() { + final ResultSet result = database.query("opencypher", + "MATCH (a:Node4006)-[r:EDGE4006]->(b:Node4006)" + + " WITH a, b, r" + + " MATCH path = (a)-[:EDGE4006*1..2]->(b)" + + " RETURN count(r) AS rc"); + + final List rows = collect(result); + assertThat(rows).hasSize(1); + assertThat(((Number) rows.get(0).getProperty("rc")).longValue()).isGreaterThan(0L); + } + + private static List collect(final ResultSet rs) { + final List list = new ArrayList<>(); + while (rs.hasNext()) + list.add(rs.next()); + return list; + } +}