Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,10 @@ private int resolveBatchSize(final CommandContext context) {
/**
* Executes the inner query with the outer row as the initial seed.
* The seed row provides variables for the inner query's WITH clause.
* <p>
* For unit subqueries (no RETURN clause), the inner query is consumed entirely
* for side effects (e.g. CREATE, DELETE) and a single empty result is returned
* so the outer row count is preserved exactly once per input row.
*/
private List<Result> executeInnerQuery(final Result outerRow, final CommandContext context) {
final CypherStatement innerStatement = subqueryClause.getInnerStatement();
Expand All @@ -334,6 +338,16 @@ private List<Result> executeInnerQuery(final Result outerRow, final CommandConte

final ResultSet resultSet = innerPlan.executeWithSeedRow(outerRow);

// Unit subquery: no RETURN clause — consume all inner rows for side effects only.
// The outer row count must be preserved (one output row per outer row).
if (innerStatement.getReturnClause() == null) {
while (resultSet.hasNext())
resultSet.next();
final List<Result> single = new ArrayList<>(1);
single.add(new ResultInternal());
return single;
}
Comment on lines +343 to +347

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The current implementation of unit subqueries (no RETURN clause) has a logic bug: it always returns a single row even if the inner query produces no results. In Cypher, a subquery acts as a filter; if the inner query is empty, the outer row should be discarded (unless it is an OPTIONAL CALL).

Additionally, the list creation can be simplified using List.of() for better readability and performance.

Suggested change
if (innerStatement.getReturnClause() == null) {
while (resultSet.hasNext())
resultSet.next();
final List<Result> single = new ArrayList<>(1);
single.add(new ResultInternal());
return single;
}
if (innerStatement.getReturnClause() == null) {
boolean hasResults = false;
while (resultSet.hasNext()) {
resultSet.next();
hasResults = true;
}
return hasResults ? List.of(new ResultInternal()) : List.of();
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partially applied: simplified to List.of(new ResultInternal()) since callers only iterate (never mutate) the returned list.

The filter semantics (return empty list when inner has no results) would be incorrect for unit subqueries. The Cypher spec states that a unit subquery (no RETURN) executes for side effects and must propagate each outer row exactly once, regardless of inner cardinality. The callers in this class already implement the filter logic for regular subqueries — applying it here would incorrectly drop outer rows when e.g. a MATCH inside the unit subquery finds nothing, which violates the spec.


final List<Result> results = new ArrayList<>();
while (resultSet.hasNext())
results.add(resultSet.next());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -301,4 +301,74 @@ void callSubqueryWithUnionFallsThrough() {
assertThat(((Number) row2.getProperty("success2")).intValue()).isEqualTo(2);
assertThat(result2.hasNext()).isFalse();
}

/**
* Issue #3944: Unit CALL subquery (no RETURN) with inner UNWIND must not multiply outer rows.
* Each outer row should appear exactly once regardless of inner UNWIND cardinality.
*/
@Test
void unitCallSubqueryWithUnwindPreservesOuterRowCount() {
database.getSchema().createVertexType("Person3944");
database.getSchema().createVertexType("Clone3944");

database.transaction(() -> {
database.command("opencypher", "CREATE (:Person3944 {name: 'Alice'})");
database.command("opencypher", "CREATE (:Person3944 {name: 'Bob'})");
database.command("opencypher", "CREATE (:Person3944 {name: 'Charlie'})");
});

database.transaction(() -> {
final ResultSet result = database.command("opencypher",
"MATCH (p:Person3944) " +
"CALL { " +
" WITH p " +
" UNWIND range(1, 2) AS i " +
" CREATE (:Clone3944 {name: p.name, id: i}) " +
"} " +
"RETURN p.name AS person " +
"ORDER BY person");

final List<Result> rows = new ArrayList<>();
while (result.hasNext())
rows.add(result.next());

assertThat(rows).as("Unit CALL subquery must not multiply outer rows by inner UNWIND cardinality").hasSize(3);
assertThat((String) rows.get(0).getProperty("person")).isEqualTo("Alice");
assertThat((String) rows.get(1).getProperty("person")).isEqualTo("Bob");
assertThat((String) rows.get(2).getProperty("person")).isEqualTo("Charlie");
});
}

/**
* Issue #3944 control: Unit CALL subquery without UNWIND should still work correctly (one row per outer row).
*/
@Test
void unitCallSubqueryWithoutUnwindPreservesOuterRowCount() {
database.getSchema().createVertexType("Person3944b");
database.getSchema().createVertexType("Clone3944b");

database.transaction(() -> {
database.command("opencypher", "CREATE (:Person3944b {name: 'Alice'})");
database.command("opencypher", "CREATE (:Person3944b {name: 'Bob'})");
});

database.transaction(() -> {
final ResultSet result = database.command("opencypher",
"MATCH (p:Person3944b) " +
"CALL { " +
" WITH p " +
" CREATE (:Clone3944b {name: p.name}) " +
"} " +
"RETURN p.name AS person " +
"ORDER BY person");

final List<Result> rows = new ArrayList<>();
while (result.hasNext())
rows.add(result.next());

assertThat(rows).as("Unit CALL subquery must produce exactly one row per outer row").hasSize(2);
assertThat((String) rows.get(0).getProperty("person")).isEqualTo("Alice");
assertThat((String) rows.get(1).getProperty("person")).isEqualTo("Bob");
});
}
}
Loading