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 e40c31bf70..86e3bd9948 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 @@ -576,6 +576,10 @@ private AbstractExecutionStep buildExecutionStepsWithOrder(final CommandContext // Get function factory from evaluator for steps that need it final CypherFunctionFactory functionFactory = expressionEvaluator != null ? expressionEvaluator.getFunctionFactory() : null; + // Track variables bound across MATCH clauses so subsequent MATCHes + // can detect already-bound variables and avoid Cartesian products + final Set boundVariables = new HashSet<>(); + // OPTIMIZATION: Check for simple COUNT(*) pattern that can use Type.count() O(1) operation // Pattern: MATCH (a:TypeName) RETURN COUNT(a) as alias final AbstractExecutionStep typeCountStep = tryCreateTypeCountOptimization(context); @@ -625,7 +629,7 @@ public String prettyPrint(final int depth, final int indent) { case MATCH: final MatchClause matchClause = entry.getTypedClause(); - currentStep = buildMatchStep(matchClause, currentStep, context); + currentStep = buildMatchStep(matchClause, currentStep, context, boundVariables); break; case WITH: @@ -801,9 +805,23 @@ private AbstractExecutionStep buildWithStep(final WithClause withClause, /** * Builds execution step for a MATCH clause. + * Backward-compatible overload without bound variable tracking. */ private AbstractExecutionStep buildMatchStep(final MatchClause matchClause, AbstractExecutionStep currentStep, final CommandContext context) { + return buildMatchStep(matchClause, currentStep, context, new HashSet<>()); + } + + /** + * Builds execution step for a MATCH clause with bound variable tracking. + * + * @param matchClause the MATCH clause to build + * @param currentStep current step in the execution chain + * @param context command context + * @param boundVariables set of variable names already bound in previous steps (updated in-place) + */ + private AbstractExecutionStep buildMatchStep(final MatchClause matchClause, AbstractExecutionStep currentStep, + final CommandContext context, final Set boundVariables) { if (!matchClause.hasPathPatterns()) { return currentStep; } @@ -826,6 +844,13 @@ private AbstractExecutionStep buildMatchStep(final MatchClause matchClause, Abst final String variable = nodePattern.getVariable() != null ? nodePattern.getVariable() : ("n" + patternIndex); matchVariables.add(variable); + // Check if this variable was already bound in a previous MATCH clause + if (boundVariables.contains(variable)) { + // Variable already bound - skip creating a new MatchNodeStep + // The bound value will be used from the input result + continue; + } + // OPTIMIZATION: Extract ID filter for this variable to avoid Cartesian product final String idFilter = extractIdFilter(whereClause, variable); final MatchNodeStep matchStep = new MatchNodeStep(variable, nodePattern, context, idFilter); @@ -848,8 +873,11 @@ private AbstractExecutionStep buildMatchStep(final MatchClause matchClause, Abst final NodePattern sourceNode = pathPattern.getFirstNode(); final String sourceVar = sourceNode.getVariable() != null ? sourceNode.getVariable() : "a"; + // Check if source node variable is already bound (either from previous MATCH or + // from being in the boundVariables set). Previously this only checked for + // unlabeled/unpropertied nodes, which broke when labels were repeated. final boolean sourceAlreadyBound = stepBeforeMatch != null && - !sourceNode.hasLabels() && !sourceNode.hasProperties(); + (boundVariables.contains(sourceVar) || (!sourceNode.hasLabels() && !sourceNode.hasProperties())); if (!sourceAlreadyBound) { matchVariables.add(sourceVar); @@ -898,7 +926,10 @@ private AbstractExecutionStep buildMatchStep(final MatchClause matchClause, Abst if (relPattern.isVariableLength()) { nextStep = new ExpandPathStep(sourceVar, pathVariable, targetVar, relPattern, context); } else { - nextStep = new MatchRelationshipStep(sourceVar, relVar, targetVar, relPattern, pathVariable, context); + // Pass target node pattern for label filtering and bound variables + // for identity checking on already-bound target variables + nextStep = new MatchRelationshipStep(sourceVar, relVar, targetVar, relPattern, pathVariable, + targetNode, boundVariables, context); } if (isOptional && matchChainStart == null) { @@ -942,6 +973,9 @@ private AbstractExecutionStep buildMatchStep(final MatchClause matchClause, Abst currentStep = optionalStep; } + // Update bound variables with newly bound variables from this MATCH + boundVariables.addAll(matchVariables); + return currentStep; } @@ -988,6 +1022,10 @@ public String prettyPrint(final int depth, final int indent) { }; } + // Track variables bound across MATCH clauses so subsequent MATCHes + // can detect already-bound variables and avoid Cartesian products + final Set legacyBoundVariables = new HashSet<>(); + // Step 1: MATCH clauses - fetch nodes // Process ALL MATCH clauses (not just the first) if (!statement.getMatchClauses().isEmpty()) { @@ -1015,6 +1053,12 @@ public String prettyPrint(final int depth, final int indent) { final String variable = nodePattern.getVariable() != null ? nodePattern.getVariable() : ("n" + patternIndex); matchVariables.add(variable); // Track variable for OPTIONAL MATCH + // Check if this variable was already bound in a previous MATCH clause + if (legacyBoundVariables.contains(variable)) { + // Variable already bound - skip creating a new MatchNodeStep + continue; + } + // OPTIMIZATION: Extract ID filter from WHERE clause (if present) for pushdown final WhereClause matchWhere = matchClause.hasWhereClause() ? matchClause.getWhereClause() : statement.getWhereClause(); final String idFilter = extractIdFilter(matchWhere, variable); @@ -1042,10 +1086,9 @@ public String prettyPrint(final int depth, final int indent) { final String sourceVar = sourceNode.getVariable() != null ? sourceNode.getVariable() : "a"; // Check if source node is already bound (for multiple MATCH clauses or OPTIONAL MATCH) - // If the source node has no labels/properties and there's a previous step, - // it's likely referring to an already-bound variable - skip creating MatchNodeStep + // Check both legacy bound variables AND the old heuristic (no labels/properties) final boolean sourceAlreadyBound = stepBeforeMatch != null && - !sourceNode.hasLabels() && !sourceNode.hasProperties(); + (legacyBoundVariables.contains(sourceVar) || (!sourceNode.hasLabels() && !sourceNode.hasProperties())); if (!sourceAlreadyBound) { // Only track the source variable if we're creating a new binding for it @@ -1109,8 +1152,9 @@ public String prettyPrint(final int depth, final int indent) { // Variable-length path - pass path variable for named path support nextStep = new ExpandPathStep(sourceVar, pathVariable, targetVar, relPattern, context); } else { - // Fixed-length relationship - pass path variable - nextStep = new MatchRelationshipStep(sourceVar, relVar, targetVar, relPattern, pathVariable, context); + // Fixed-length relationship - pass path variable, target node pattern, and bound variables + nextStep = new MatchRelationshipStep(sourceVar, relVar, targetVar, relPattern, pathVariable, + targetNode, legacyBoundVariables, context); } // Chain the relationship step @@ -1168,6 +1212,9 @@ public String prettyPrint(final int depth, final int indent) { // The output of OptionalMatchStep becomes currentStep currentStep = optionalStep; } + + // Update bound variables with newly bound variables from this MATCH + legacyBoundVariables.addAll(matchVariables); } else { // Phase 1: Use raw pattern string - create a simple stub final ResultInternal stubResult = new ResultInternal(); diff --git a/engine/src/main/java/com/arcadedb/query/opencypher/executor/steps/MatchRelationshipStep.java b/engine/src/main/java/com/arcadedb/query/opencypher/executor/steps/MatchRelationshipStep.java index db9731859d..39af9a2870 100644 --- a/engine/src/main/java/com/arcadedb/query/opencypher/executor/steps/MatchRelationshipStep.java +++ b/engine/src/main/java/com/arcadedb/query/opencypher/executor/steps/MatchRelationshipStep.java @@ -22,6 +22,7 @@ import com.arcadedb.graph.Edge; import com.arcadedb.graph.Vertex; import com.arcadedb.query.opencypher.ast.Direction; +import com.arcadedb.query.opencypher.ast.NodePattern; import com.arcadedb.query.opencypher.ast.RelationshipPattern; import com.arcadedb.query.opencypher.traversal.TraversalPath; import com.arcadedb.query.sql.executor.AbstractExecutionStep; @@ -34,6 +35,7 @@ import java.util.Iterator; import java.util.List; import java.util.NoSuchElementException; +import java.util.Set; /** * Execution step for matching relationship patterns. @@ -50,6 +52,8 @@ public class MatchRelationshipStep extends AbstractExecutionStep { private final String targetVariable; private final RelationshipPattern pattern; private final String pathVariable; + private final NodePattern targetNodePattern; + private final Set boundVariableNames; /** * Creates a match relationship step. @@ -77,12 +81,32 @@ public MatchRelationshipStep(final String sourceVariable, final String relations */ public MatchRelationshipStep(final String sourceVariable, final String relationshipVariable, final String targetVariable, final RelationshipPattern pattern, final String pathVariable, final CommandContext context) { + this(sourceVariable, relationshipVariable, targetVariable, pattern, pathVariable, null, null, context); + } + + /** + * Creates a match relationship step with target node filtering and bound variable awareness. + * + * @param sourceVariable variable name for source vertex + * @param relationshipVariable variable name for relationship (can be null) + * @param targetVariable variable name for target vertex + * @param pattern relationship pattern to match + * @param pathVariable path variable name (e.g., p in p = (a)-[r]->(b)), can be null + * @param targetNodePattern target node pattern for label filtering (can be null) + * @param boundVariableNames set of variable names already bound in previous steps (can be null) + * @param context command context + */ + public MatchRelationshipStep(final String sourceVariable, final String relationshipVariable, final String targetVariable, + final RelationshipPattern pattern, final String pathVariable, final NodePattern targetNodePattern, + final Set boundVariableNames, final CommandContext context) { super(context); this.sourceVariable = sourceVariable; this.relationshipVariable = relationshipVariable; this.targetVariable = targetVariable; this.pattern = pattern; this.pathVariable = pathVariable; + this.targetNodePattern = targetNodePattern; + this.boundVariableNames = boundVariableNames; } @Override @@ -130,11 +154,29 @@ private void fetchMore(final int n) { final Edge edge = currentEdges.next(); final Vertex targetVertex = getTargetVertex(edge, (Vertex) lastResult.getProperty(sourceVariable)); - // Filter by target type if specified + // Filter by edge type if specified if (pattern.hasTypes() && !matchesEdgeType(edge)) { continue; } + // Filter by target node label if specified in the pattern + if (targetNodePattern != null && targetNodePattern.hasLabels()) { + if (!matchesTargetLabel(targetVertex)) { + continue; + } + } + + // If the target variable is already bound from a previous step, + // verify the traversed vertex matches the bound value (identity check) + if (boundVariableNames != null && boundVariableNames.contains(targetVariable)) { + final Object boundValue = lastResult.getProperty(targetVariable); + if (boundValue instanceof Vertex) { + if (!((Vertex) boundValue).getIdentity().equals(targetVertex.getIdentity())) { + continue; + } + } + } + // Create result with edge and target vertex final ResultInternal result = new ResultInternal(); @@ -232,6 +274,23 @@ private Vertex getTargetVertex(final Edge edge, final Vertex sourceVertex) { } } + /** + * Checks if a target vertex matches the label constraints from the target node pattern. + */ + private boolean matchesTargetLabel(final Vertex vertex) { + if (targetNodePattern == null || !targetNodePattern.hasLabels()) { + return true; + } + + final String vertexType = vertex.getTypeName(); + for (final String label : targetNodePattern.getLabels()) { + if (label.equals(vertexType)) { + return true; + } + } + return false; + } + /** * Checks if an edge matches the type filter. */ diff --git a/engine/src/test/java/com/arcadedb/query/opencypher/CypherLabelFilteringTest.java b/engine/src/test/java/com/arcadedb/query/opencypher/CypherLabelFilteringTest.java new file mode 100644 index 0000000000..b3042dbb0c --- /dev/null +++ b/engine/src/test/java/com/arcadedb/query/opencypher/CypherLabelFilteringTest.java @@ -0,0 +1,263 @@ +/* + * 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.graph.MutableVertex; +import com.arcadedb.graph.Vertex; +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.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests for correct label filtering in Cypher MATCH and OPTIONAL MATCH clauses. + * Specifically tests that: + * 1. Target vertices in relationship patterns are filtered by label + * 2. Already-bound variables with labels in subsequent MATCH clauses work correctly + * 3. The query from GitHub issue (multiple OPTIONAL MATCH with labels) returns correct data + */ +public class CypherLabelFilteringTest { + private Database database; + private String chunkId; + + @BeforeEach + void setUp() { + database = new DatabaseFactory("./target/databases/cypher-label-filtering").create(); + database.getSchema().createVertexType("CHUNK"); + database.getSchema().createVertexType("DOCUMENT"); + database.getSchema().createVertexType("NER"); + database.getSchema().createVertexType("THEME"); + database.getSchema().createEdgeType("in"); + database.getSchema().createEdgeType("topic"); + database.getSchema().createEdgeType("related"); + + database.transaction(() -> { + // Create graph: CHUNK <- in - DOCUMENT + // CHUNK <- in - NER (x3) + // CHUNK <- topic - THEME (x2) + // NER -> related -> NER (one connection between nerOne and nerTwo) + MutableVertex chunk = database.newVertex("CHUNK"); + chunk.set("name", "chunk1"); + chunk.save(); + chunkId = chunk.getIdentity().toString(); + + MutableVertex doc = database.newVertex("DOCUMENT"); + doc.set("name", "doc1"); + doc.save(); + chunk.newEdge("in", doc, true, (Object[]) null); + + MutableVertex ner1 = database.newVertex("NER"); + ner1.set("name", "ner_1"); + ner1.save(); + ner1.newEdge("in", chunk, true, (Object[]) null); + + MutableVertex ner2 = database.newVertex("NER"); + ner2.set("name", "ner_2"); + ner2.save(); + ner2.newEdge("in", chunk, true, (Object[]) null); + + MutableVertex ner3 = database.newVertex("NER"); + ner3.set("name", "ner_3"); + ner3.save(); + ner3.newEdge("in", chunk, true, (Object[]) null); + + // ner1 -> related -> ner2, and ner2 connects back to chunk via "in" + ner1.newEdge("related", ner2, true, (Object[]) null); + + MutableVertex theme1 = database.newVertex("THEME"); + theme1.set("name", "theme_1"); + theme1.save(); + theme1.newEdge("topic", chunk, true, (Object[]) null); + + MutableVertex theme2 = database.newVertex("THEME"); + theme2.set("name", "theme_2"); + theme2.save(); + theme2.newEdge("topic", chunk, true, (Object[]) null); + }); + } + + @AfterEach + void tearDown() { + if (database != null) { + database.drop(); + database = null; + } + } + + /** + * Tests that target node label filtering works in MatchRelationshipStep. + * The pattern (a:CHUNK)<-[r:in]-(b:NER) should only return NER vertices as b, + * not DOCUMENT vertices (which are also connected via "in" edges). + */ + @Test + void testTargetNodeLabelFiltering() { + database.transaction(() -> { + final ResultSet rs = database.query("opencypher", + "MATCH (chunk:CHUNK) WHERE ID(chunk) = $_id " + + "OPTIONAL MATCH (chunk:CHUNK)<-[r:in]-(target:NER) " + + "RETURN chunk.name AS chunkName, collect(DISTINCT target) AS targets", + Map.of("_id", chunkId)); + + assertTrue(rs.hasNext()); + Result result = rs.next(); + + assertEquals("chunk1", result.getProperty("chunkName")); + List targets = (List) result.getProperty("targets"); + // Should only get NER vertices (3), not DOCUMENT vertices + assertEquals(3, targets.size(), "Expected 3 NER targets, got " + targets.size()); + + // Verify all targets are NER type + for (Object target : targets) { + assertInstanceOf(Vertex.class, target); + assertEquals("NER", ((Vertex) target).getTypeName(), + "Target should be NER, not " + ((Vertex) target).getTypeName()); + } + + assertFalse(rs.hasNext()); + }); + } + + /** + * Tests that already-bound variables with labels in subsequent MATCH clauses + * don't cause Cartesian products or incorrect data. + * The variable searchedChunk is bound in the first MATCH, and reused with :CHUNK label + * in the second MATCH - should use the already-bound value. + */ + @Test + void testBoundVariableWithLabelInSubsequentMatch() { + database.transaction(() -> { + final ResultSet rs = database.query("opencypher", + "MATCH (searchedChunk:CHUNK) WHERE ID(searchedChunk) = $_id " + + "MATCH (sourceDoc:DOCUMENT)<-[chunkDocRel:in]-(searchedChunk:CHUNK) " + + "RETURN searchedChunk.name AS chunkName, sourceDoc.name AS docName", + Map.of("_id", chunkId)); + + assertTrue(rs.hasNext()); + Result result = rs.next(); + + assertEquals("chunk1", result.getProperty("chunkName")); + assertEquals("doc1", result.getProperty("docName")); + + // Should be exactly 1 result, not a Cartesian product + assertFalse(rs.hasNext()); + }); + } + + /** + * Tests the full query pattern from the user's bug report. + * This is the exact query structure that was returning incorrect results: + * - searchedChunks should only contain CHUNK vertices + * - nerOnes should contain NER vertices + * - nerTwos should contain the NER vertices connected from nerOne + * - themes should contain THEME vertices + */ + @Test + void testFullQueryWithLabelsOnBoundVariables() { + database.transaction(() -> { + final ResultSet rs = database.command("opencypher", + "MATCH (searchedChunk:CHUNK) WHERE ID(searchedChunk) IN $_ids " + + "MATCH (sourceDoc:DOCUMENT)<-[chunkDocRel:in]-(searchedChunk:CHUNK) " + + "OPTIONAL MATCH (searchedChunk:CHUNK)<-[chunkNerOneRel:in]-(nerOne:NER) " + + "OPTIONAL MATCH (nerOne:NER)-[nerOneNerTwoRel:related]->(nerTwo:NER)-[chunkNerTwoRel:in]->(searchedChunk:CHUNK) " + + "OPTIONAL MATCH (searchedChunk:CHUNK)<-[themeToChunkRel:topic]-(theme:THEME) " + + "RETURN " + + " collect(DISTINCT searchedChunk) AS searchedChunks, " + + " collect(DISTINCT sourceDoc) AS sourceDocs, " + + " collect(DISTINCT nerOne) AS nerOnes, " + + " collect(DISTINCT nerTwo) AS nerTwos, " + + " collect(DISTINCT theme) AS themes", + Map.of("_ids", List.of(chunkId))); + + assertTrue(rs.hasNext()); + Result result = rs.next(); + + List searchedChunks = (List) result.getProperty("searchedChunks"); + List sourceDocs = (List) result.getProperty("sourceDocs"); + List nerOnes = (List) result.getProperty("nerOnes"); + List nerTwos = (List) result.getProperty("nerTwos"); + List themes = (List) result.getProperty("themes"); + + // searchedChunks should contain exactly 1 CHUNK vertex + assertEquals(1, searchedChunks.size(), "Expected 1 CHUNK in searchedChunks"); + assertEquals("CHUNK", ((Vertex) searchedChunks.get(0)).getTypeName()); + + // sourceDocs should contain exactly 1 DOCUMENT vertex + assertEquals(1, sourceDocs.size(), "Expected 1 DOCUMENT in sourceDocs"); + + // nerOnes should contain 3 NER vertices + assertEquals(3, nerOnes.size(), "Expected 3 NER in nerOnes"); + for (Object ner : nerOnes) { + assertEquals("NER", ((Vertex) ner).getTypeName()); + } + + // nerTwos should contain 1 NER vertex (ner2, connected from ner1 via "related") + assertEquals(1, nerTwos.size(), "Expected 1 NER in nerTwos"); + assertEquals("NER", ((Vertex) nerTwos.get(0)).getTypeName()); + + // themes should contain 2 THEME vertices + assertEquals(2, themes.size(), "Expected 2 THEME in themes"); + + assertFalse(rs.hasNext()); + }); + } + + /** + * Tests that searchedChunks does NOT contain NER nodes. + * This was the specific bug reported: searchedChunks contained NER nodes + * because target label filtering was missing. + */ + @Test + void testSearchedChunksDoNotContainNERNodes() { + database.transaction(() -> { + final ResultSet rs = database.command("opencypher", + "MATCH (searchedChunk:CHUNK) WHERE ID(searchedChunk) IN $_ids " + + "MATCH (sourceDoc:DOCUMENT)<-[chunkDocRel:in]-(searchedChunk:CHUNK) " + + "OPTIONAL MATCH (searchedChunk:CHUNK)<-[chunkNerOneRel:in]-(nerOne:NER) " + + "RETURN " + + " collect(DISTINCT searchedChunk) AS searchedChunks, " + + " collect(DISTINCT nerOne) AS nerOnes", + Map.of("_ids", List.of(chunkId))); + + assertTrue(rs.hasNext()); + Result result = rs.next(); + + List searchedChunks = (List) result.getProperty("searchedChunks"); + List nerOnes = (List) result.getProperty("nerOnes"); + + // searchedChunks must ONLY contain CHUNK vertices - never NER nodes + assertEquals(1, searchedChunks.size(), "Expected 1 CHUNK in searchedChunks"); + for (Object obj : searchedChunks) { + assertInstanceOf(Vertex.class, obj); + assertEquals("CHUNK", ((Vertex) obj).getTypeName(), + "searchedChunks should only contain CHUNK vertices, not " + ((Vertex) obj).getTypeName()); + } + + // nerOnes should contain exactly 3 NER vertices + assertEquals(3, nerOnes.size(), "Expected 3 NER in nerOnes"); + }); + } +} diff --git a/engine/src/test/java/com/arcadedb/query/opencypher/Issue3218Test.java b/engine/src/test/java/com/arcadedb/query/opencypher/Issue3218Test.java index 991721e2bd..2fd3d2a8d8 100644 --- a/engine/src/test/java/com/arcadedb/query/opencypher/Issue3218Test.java +++ b/engine/src/test/java/com/arcadedb/query/opencypher/Issue3218Test.java @@ -121,11 +121,12 @@ void testOptionalMatchCartesianExplosion() { // Query with multiple OPTIONAL MATCH - this creates Cartesian product // Without DISTINCT: 10 NER × 5 THEME = 50 intermediate rows // With collect(DISTINCT ...): should get 1 chunk, 1 doc, 10 NERs, 5 themes + // Note: Labels can now be repeated on bound variables (bug fix for label filtering) final ResultSet rs = database.command("opencypher", "MATCH (searchedChunk:CHUNK) WHERE ID(searchedChunk) IN $_ids " + - "MATCH (sourceDoc:DOCUMENT)<-[chunkDocRel:in]-(searchedChunk) " + - "OPTIONAL MATCH (searchedChunk)<-[chunkNerOneRel:in]-(nerOne:NER) " + - "OPTIONAL MATCH (searchedChunk)<-[themeToChunkRel:topic]-(theme:THEME) " + + "MATCH (sourceDoc:DOCUMENT)<-[chunkDocRel:in]-(searchedChunk:CHUNK) " + + "OPTIONAL MATCH (searchedChunk:CHUNK)<-[chunkNerOneRel:in]-(nerOne:NER) " + + "OPTIONAL MATCH (searchedChunk:CHUNK)<-[themeToChunkRel:topic]-(theme:THEME) " + "RETURN " + " collect(DISTINCT searchedChunk) AS searchedChunks, " + " collect(DISTINCT sourceDoc) AS sourceDocs, " + @@ -188,12 +189,11 @@ void testSingleOptionalMatch() { }); database.transaction(() -> { - // Note: We don't repeat the label in OPTIONAL MATCH as there's a known bug - // where repeating the label breaks pattern matching (returns null instead of matching nodes) + // Labels can now be repeated on bound variables (bug fix for label filtering) final ResultSet rs = database.command("opencypher", "MATCH (searchedChunk:CHUNK) WHERE ID(searchedChunk) = $_id " + - "MATCH (sourceDoc:DOCUMENT)<-[chunkDocRel:in]-(searchedChunk) " + - "OPTIONAL MATCH (searchedChunk)<-[chunkNerOneRel:in]-(nerOne:NER) " + + "MATCH (sourceDoc:DOCUMENT)<-[chunkDocRel:in]-(searchedChunk:CHUNK) " + + "OPTIONAL MATCH (searchedChunk:CHUNK)<-[chunkNerOneRel:in]-(nerOne:NER) " + "RETURN " + " collect(DISTINCT searchedChunk) AS searchedChunks, " + " collect(DISTINCT sourceDoc) AS sourceDocs, " + @@ -249,12 +249,12 @@ void testCartesianProductSize() { database.transaction(() -> { // Without DISTINCT, we should see the Cartesian product: 5 * 3 = 15 rows - // Note: Don't repeat labels after first declaration due to known bug + // Labels can now be repeated on bound variables (bug fix for label filtering) final ResultSet rs = database.query("opencypher", "MATCH (searchedChunk:CHUNK) WHERE ID(searchedChunk) = $_id " + - "MATCH (sourceDoc:DOCUMENT)<-[chunkDocRel:in]-(searchedChunk) " + - "OPTIONAL MATCH (searchedChunk)<-[chunkNerOneRel:in]-(nerOne:NER) " + - "OPTIONAL MATCH (searchedChunk)<-[themeToChunkRel:topic]-(theme:THEME) " + + "MATCH (sourceDoc:DOCUMENT)<-[chunkDocRel:in]-(searchedChunk:CHUNK) " + + "OPTIONAL MATCH (searchedChunk:CHUNK)<-[chunkNerOneRel:in]-(nerOne:NER) " + + "OPTIONAL MATCH (searchedChunk:CHUNK)<-[themeToChunkRel:topic]-(theme:THEME) " + "RETURN count(*) as rowCount", Map.of("_id", chunkId));