Skip to content

Commit bf373a6

Browse files
committed
#3944 fix: unit CALL subquery with UNWIND must not multiply outer rows (#3945)
(cherry picked from commit bae159c)
1 parent 8155afd commit bf373a6

3 files changed

Lines changed: 88 additions & 4 deletions

File tree

engine/src/main/java/com/arcadedb/query/opencypher/executor/steps/SubqueryStep.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,10 @@ private int resolveBatchSize(final CommandContext context) {
325325
/**
326326
* Executes the inner query with the outer row as the initial seed.
327327
* The seed row provides variables for the inner query's WITH clause.
328+
* <p>
329+
* For unit subqueries (no RETURN clause), the inner query is consumed entirely
330+
* for side effects (e.g. CREATE, DELETE) and a single empty result is returned
331+
* so the outer row count is preserved exactly once per input row.
328332
*/
329333
private List<Result> executeInnerQuery(final Result outerRow, final CommandContext context) {
330334
final CypherStatement innerStatement = subqueryClause.getInnerStatement();
@@ -334,6 +338,14 @@ private List<Result> executeInnerQuery(final Result outerRow, final CommandConte
334338

335339
final ResultSet resultSet = innerPlan.executeWithSeedRow(outerRow);
336340

341+
// Unit subquery: no RETURN clause — consume all inner rows for side effects only.
342+
// The outer row count must be preserved (one output row per outer row).
343+
if (innerStatement.getReturnClause() == null) {
344+
while (resultSet.hasNext())
345+
resultSet.next();
346+
return List.of(new ResultInternal());
347+
}
348+
337349
final List<Result> results = new ArrayList<>();
338350
while (resultSet.hasNext())
339351
results.add(resultSet.next());

engine/src/test/java/com/arcadedb/query/opencypher/OpenCypherSubqueryTest.java

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,4 +301,74 @@ void callSubqueryWithUnionFallsThrough() {
301301
assertThat(((Number) row2.getProperty("success2")).intValue()).isEqualTo(2);
302302
assertThat(result2.hasNext()).isFalse();
303303
}
304+
305+
/**
306+
* Issue #3944: Unit CALL subquery (no RETURN) with inner UNWIND must not multiply outer rows.
307+
* Each outer row should appear exactly once regardless of inner UNWIND cardinality.
308+
*/
309+
@Test
310+
void unitCallSubqueryWithUnwindPreservesOuterRowCount() {
311+
database.getSchema().createVertexType("Person3944");
312+
database.getSchema().createVertexType("Clone3944");
313+
314+
database.transaction(() -> {
315+
database.command("opencypher", "CREATE (:Person3944 {name: 'Alice'})");
316+
database.command("opencypher", "CREATE (:Person3944 {name: 'Bob'})");
317+
database.command("opencypher", "CREATE (:Person3944 {name: 'Charlie'})");
318+
});
319+
320+
database.transaction(() -> {
321+
final ResultSet result = database.command("opencypher",
322+
"MATCH (p:Person3944) " +
323+
"CALL { " +
324+
" WITH p " +
325+
" UNWIND range(1, 2) AS i " +
326+
" CREATE (:Clone3944 {name: p.name, id: i}) " +
327+
"} " +
328+
"RETURN p.name AS person " +
329+
"ORDER BY person");
330+
331+
final List<Result> rows = new ArrayList<>();
332+
while (result.hasNext())
333+
rows.add(result.next());
334+
335+
assertThat(rows).as("Unit CALL subquery must not multiply outer rows by inner UNWIND cardinality").hasSize(3);
336+
assertThat((String) rows.get(0).getProperty("person")).isEqualTo("Alice");
337+
assertThat((String) rows.get(1).getProperty("person")).isEqualTo("Bob");
338+
assertThat((String) rows.get(2).getProperty("person")).isEqualTo("Charlie");
339+
});
340+
}
341+
342+
/**
343+
* Issue #3944 control: Unit CALL subquery without UNWIND should still work correctly (one row per outer row).
344+
*/
345+
@Test
346+
void unitCallSubqueryWithoutUnwindPreservesOuterRowCount() {
347+
database.getSchema().createVertexType("Person3944b");
348+
database.getSchema().createVertexType("Clone3944b");
349+
350+
database.transaction(() -> {
351+
database.command("opencypher", "CREATE (:Person3944b {name: 'Alice'})");
352+
database.command("opencypher", "CREATE (:Person3944b {name: 'Bob'})");
353+
});
354+
355+
database.transaction(() -> {
356+
final ResultSet result = database.command("opencypher",
357+
"MATCH (p:Person3944b) " +
358+
"CALL { " +
359+
" WITH p " +
360+
" CREATE (:Clone3944b {name: p.name}) " +
361+
"} " +
362+
"RETURN p.name AS person " +
363+
"ORDER BY person");
364+
365+
final List<Result> rows = new ArrayList<>();
366+
while (result.hasNext())
367+
rows.add(result.next());
368+
369+
assertThat(rows).as("Unit CALL subquery must produce exactly one row per outer row").hasSize(2);
370+
assertThat((String) rows.get(0).getProperty("person")).isEqualTo("Alice");
371+
assertThat((String) rows.get(1).getProperty("person")).isEqualTo("Bob");
372+
});
373+
}
304374
}

grpcw/src/main/java/com/arcadedb/server/grpc/ArcadeDbGrpcService.java

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1726,10 +1726,10 @@ public void onNext(final GraphBatchChunk chunk) {
17261726
}
17271727
} catch (final Exception e) {
17281728
errorSent[0] = true;
1729+
// Null batchRef so onCompleted skips processing; skip closeQuietly to avoid blocking the
1730+
// gRPC thread via async.waitCompletion() (buffered edges have no open transaction).
1731+
batchRef.set(null);
17291732
resp.onError(Status.INTERNAL.withDescription("graphBatchLoad: " + e.getMessage()).asException());
1730-
// Note: closeQuietly may flush/commit partial data. GraphBatch has no rollback path by design
1731-
// (same as the HTTP batch endpoint). Callers should treat batch loading as non-atomic.
1732-
closeQuietly(batchRef.get());
17331733
return;
17341734
} finally {
17351735
if (!cancelled.get() && !errorSent[0])
@@ -1739,11 +1739,13 @@ public void onNext(final GraphBatchChunk chunk) {
17391739

17401740
@Override
17411741
public void onError(final Throwable t) {
1742-
closeQuietly(batchRef.get());
1742+
closeQuietly(batchRef.getAndSet(null));
17431743
}
17441744

17451745
@Override
17461746
public void onCompleted() {
1747+
if (errorSent[0])
1748+
return;
17471749
try {
17481750
final GraphBatch batch = batchRef.get();
17491751
if (batch == null) {

0 commit comments

Comments
 (0)