Skip to content

Commit 8d407ed

Browse files
committed
2 parents eb218db + ca3dcb7 commit 8d407ed

2 files changed

Lines changed: 55 additions & 15 deletions

File tree

engine/src/main/java/com/arcadedb/query/opencypher/executor/CypherExecutionPlan.java

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1551,10 +1551,14 @@ private AbstractExecutionStep buildMatchStep(final MatchClause matchClause, Abst
15511551
directionOverride = null;
15521552
}
15531553

1554-
// Track relationship and target variables for cross-MATCH uniqueness scoping
1555-
if (relVar != null)
1554+
// Track relationship and target variables for cross-MATCH uniqueness scoping.
1555+
// Only add variables that are NEW to this MATCH clause — already-bound variables
1556+
// (from previous MATCHes or WITH) should not be treated as new match variables,
1557+
// otherwise OPTIONAL MATCH will incorrectly classify them when setting NULLs.
1558+
if (relVar != null && !boundVariables.contains(relVar))
15561559
matchVariables.add(relVar);
1557-
matchVariables.add(effectiveTargetVar);
1560+
if (!boundVariables.contains(effectiveTargetVar))
1561+
matchVariables.add(effectiveTargetVar);
15581562

15591563
AbstractExecutionStep nextStep;
15601564
if (relPattern.isVariableLength()) {
@@ -2790,16 +2794,18 @@ private AbstractExecutionStep tryOptimizeMatchCountReturn(
27902794
if (returnClause.isDistinct())
27912795
return null;
27922796

2793-
// Find the single non-optional MATCH clause
2797+
// Find the single non-optional MATCH clause.
2798+
// Bail out if any OPTIONAL MATCH exists — the RETURN may reference variables
2799+
// from the OPTIONAL MATCH, which this optimization does not evaluate.
27942800
MatchClause matchClause = null;
27952801
int matchCount = 0;
27962802
for (final ClauseEntry entry : clausesInOrder) {
27972803
if (entry.getType() == ClauseEntry.ClauseType.MATCH) {
27982804
final MatchClause mc = entry.getTypedClause();
2799-
if (!mc.isOptional()) {
2800-
matchClause = mc;
2801-
matchCount++;
2802-
}
2805+
if (mc.isOptional())
2806+
return null;
2807+
matchClause = mc;
2808+
matchCount++;
28032809
}
28042810
}
28052811
if (matchCount != 1 || matchClause == null)

engine/src/main/java/com/arcadedb/query/opencypher/planner/CypherExecutionPlanner.java

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828

2929
import java.util.ArrayList;
3030
import java.util.Collections;
31+
import java.util.EnumSet;
3132
import java.util.HashSet;
3233
import java.util.List;
3334
import java.util.Map;
@@ -40,6 +41,14 @@
4041
* @author Luca Garulli (l.garulli--(at)--arcadedata.com)
4142
*/
4243
public class CypherExecutionPlanner {
44+
private static final Set<ClauseEntry.ClauseType> MUTATING_CLAUSES = EnumSet.of(
45+
ClauseEntry.ClauseType.DELETE,
46+
ClauseEntry.ClauseType.SET,
47+
ClauseEntry.ClauseType.REMOVE,
48+
ClauseEntry.ClauseType.CREATE,
49+
ClauseEntry.ClauseType.MERGE
50+
);
51+
4352
private final DatabaseInternal database;
4453
private final CypherStatement statement;
4554
private final Map<String, Object> parameters;
@@ -213,23 +222,48 @@ private boolean shouldUseOptimizer() {
213222
}
214223
}
215224

216-
// The optimizer path (buildExecutionStepsWithOptimizer) only supports a fixed clause
217-
// ordering: MATCH(es) → WITH → RETURN. Write clauses (CREATE/SET/DELETE/MERGE) are also handled.
218-
// Disable for queries with clauses that break this assumption.
225+
// The optimizer path (buildExecutionStepsWithOptimizer) applies clauses in a fixed order
226+
// (MATCH → CREATE → SET → DELETE → REMOVE → MERGE → UNWIND → WITH → RETURN).
227+
// This breaks queries where clause ordering matters, e.g. WITH before DELETE or UNWIND before SET.
228+
// Fall back to ordered execution when write/mutating clauses are interleaved with WITH/UNWIND,
229+
// or when MATCH appears after WITH (MATCH-WITH-MATCH pattern).
219230
if (statement.getClausesInOrder() != null) {
220231
int createCount = 0;
221232
int mergeCount = 0;
222233
int deleteCount = 0;
234+
boolean seenWith = false;
235+
boolean seenUnwind = false;
223236
for (final ClauseEntry clause : statement.getClausesInOrder()) {
224237
final ClauseEntry.ClauseType type = clause.getType();
225-
if (type == ClauseEntry.ClauseType.FOREACH || type == ClauseEntry.ClauseType.CALL)
238+
239+
// Validate ordering before updating state
240+
if ((seenWith || seenUnwind) && MUTATING_CLAUSES.contains(type))
241+
return false;
242+
if (seenWith && type == ClauseEntry.ClauseType.MATCH)
226243
return false;
227-
if (type == ClauseEntry.ClauseType.CREATE)
244+
245+
switch (type) {
246+
case FOREACH:
247+
case CALL:
248+
return false;
249+
case WITH:
250+
seenWith = true;
251+
break;
252+
case UNWIND:
253+
seenUnwind = true;
254+
break;
255+
case CREATE:
228256
createCount++;
229-
else if (type == ClauseEntry.ClauseType.MERGE)
257+
break;
258+
case MERGE:
230259
mergeCount++;
231-
else if (type == ClauseEntry.ClauseType.DELETE)
260+
break;
261+
case DELETE:
232262
deleteCount++;
263+
break;
264+
default:
265+
break;
266+
}
233267
}
234268
// Multiple CREATE/MERGE/DELETE clauses not handled by optimizer path
235269
if (createCount > 1 || mergeCount > 1 || (deleteCount > 0 && mergeCount > 0))

0 commit comments

Comments
 (0)