Merge pull request #2764 from gaurav0107/fix/2742-cypher-return-zero-for-ungrouped-count-o

fix(cypher): return one row for an ungrouped aggregate over empty input
This commit is contained in:
Martin Vogel authored and GitHub committed 2026-10-26 00:45:45 +01:00
commit 0433142a15
2 files changed
+135

No files matched your search

+33
View File
@@ -5370,6 +5370,34 @@ static void ret_agg_emit_row(cbm_return_clause_t *ret, ret_agg_entry_t *agg, res
rb_add_row(rb, row);
}
/* True when every RETURN item is an aggregate, so the whole input is one group. */
static bool ret_agg_is_ungrouped(const cbm_return_clause_t *ret) {
for (int ci = 0; ci < ret->count; ci++) {
if (!is_aggregate_func(ret->items[ci].func)) {
return false;
}
}
return true;
}
/* The one row an ungrouped aggregation answers when no binding reached it
* (#2742): count and sum are 0, collect is an empty list, and avg/min/max have
* no value, rendered as "" like any other null. */
static void ret_agg_emit_empty_row(cbm_return_clause_t *ret, result_builder_t *rb) {
const char *row[CBM_SZ_32];
for (int ci = 0; ci < ret->count && ci < CBM_SZ_32; ci++) {
const char *func = ret->items[ci].func;
if (strcmp(func, "COUNT") == 0 || strcmp(func, "SUM") == 0) {
row[ci] = "0";
} else if (strcmp(func, "COLLECT") == 0) {
row[ci] = "[]";
} else {
row[ci] = "";
}
}
rb_add_row(rb, row);
}
static void ret_sink_feed_agg(ret_sink_t *s, binding_t *bindings, int bind_count) {
cbm_return_clause_t *ret = s->ret;
for (int bi = 0; bi < bind_count && !s->done; bi++) {
@@ -5586,6 +5614,11 @@ static void ret_sink_finish(ret_sink_t *s) {
for (int a = 0; a < s->agg_count; a++) {
ret_agg_emit_row(ret, &s->aggs[a], rb);
}
/* Without grouping keys the input is a single group even when it is
* empty; a grouped aggregation over no rows has no group to report. */
if (s->agg_count == 0 && ret_agg_is_ungrouped(ret)) {
ret_agg_emit_empty_row(ret, rb);
}
}
ret_sink_free(s);
+102
View File
@@ -4958,6 +4958,107 @@ TEST(cypher_issue305_count_star_alias) {
PASS();
}
/* Runs one query and asserts it answers exactly one row with `want` in the
* first column. */
static int expect_one_row(cbm_store_t *s, const char *project, const char *query,
const char *want) {
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s, query, project, 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_NULL(r.error);
ASSERT_EQ(r.row_count, 1);
ASSERT_STR_EQ(r.rows[0][0], want);
cbm_cypher_result_free(&r);
return 0;
}
/* #2742: an ungrouped aggregation is one row even when MATCH/WHERE produced no
* input rows. count(*) over nothing is 0, not an empty result: a caller asking
* "how many?" must not have to read "no rows" as "zero". */
TEST(cypher_count_star_empty_input_returns_zero) {
/* Empty graph: the project exists but holds no nodes. */
cbm_store_t *empty = cbm_store_open_memory();
cbm_store_upsert_project(empty, "empty", "/tmp/empty");
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(empty, "MATCH (n) RETURN count(*) AS total", "empty", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_NULL(r.error);
ASSERT_EQ(r.col_count, 1);
ASSERT_STR_EQ(r.columns[0], "total");
ASSERT_EQ(r.row_count, 1);
ASSERT_STR_EQ(r.rows[0][0], "0");
cbm_cypher_result_free(&r);
cbm_store_close(empty);
/* Nonempty graph, every row filtered out. */
cbm_store_t *s = setup_cypher_store();
ASSERT_EQ(expect_one_row(s, "test",
"MATCH (f:Function) WHERE f.name = \"NoSuchFunction\" "
"RETURN count(*) AS total",
"0"),
0);
ASSERT_EQ(expect_one_row(s, "test",
"MATCH (a)-[:CALLS]->(b) WHERE b.name = \"NoSuchFunction\" "
"RETURN count(*)",
"0"),
0);
ASSERT_EQ(expect_one_row(s, "test", "MATCH (n:NoSuchLabel) RETURN count(*)", "0"), 0);
ASSERT_EQ(expect_one_row(s, "test",
"MATCH (f:Function) WHERE f.name = \"NoSuchFunction\" "
"RETURN count(f) AS n",
"0"),
0);
ASSERT_EQ(expect_one_row(s, "test",
"MATCH (f:Function) WHERE f.name = \"NoSuchFunction\" "
"RETURN count(DISTINCT f.name) AS n",
"0"),
0);
/* The other aggregates in the same row take their empty-input values:
* sum 0, collect [], and avg/min/max null (rendered as ""). */
rc = cbm_cypher_execute(s,
"MATCH (f:Function) WHERE f.name = \"NoSuchFunction\" "
"RETURN count(*) AS c, sum(f.start_line) AS s, "
"avg(f.start_line) AS a, min(f.start_line) AS lo, "
"max(f.start_line) AS hi, collect(f.name) AS names",
"test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_NULL(r.error);
ASSERT_EQ(r.row_count, 1);
ASSERT_STR_EQ(r.rows[0][0], "0");
ASSERT_STR_EQ(r.rows[0][1], "0");
ASSERT_STR_EQ(r.rows[0][2], "");
ASSERT_STR_EQ(r.rows[0][3], "");
ASSERT_STR_EQ(r.rows[0][4], "");
ASSERT_STR_EQ(r.rows[0][5], "[]");
cbm_cypher_result_free(&r);
/* A grouped aggregation has no group to report: still no rows. */
rc = cbm_cypher_execute(s,
"MATCH (f:Function) WHERE f.name = \"NoSuchFunction\" "
"RETURN f.label, count(*)",
"test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_NULL(r.error);
ASSERT_EQ(r.row_count, 0);
cbm_cypher_result_free(&r);
/* SKIP still applies to the single aggregate row. */
rc = cbm_cypher_execute(s,
"MATCH (f:Function) WHERE f.name = \"NoSuchFunction\" "
"RETURN count(*) SKIP 1",
"test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_NULL(r.error);
ASSERT_EQ(r.row_count, 0);
cbm_cypher_result_free(&r);
/* Nonempty input is unchanged. */
ASSERT_EQ(expect_one_row(s, "test", "MATCH (f:Function) RETURN count(*) AS total", "4"), 0);
cbm_store_close(s);
PASS();
}
/* Regression: projecting several computed/JSON properties in one row must yield
* DISTINCT values. node_prop previously returned a single shared static buffer,
* so every such column aliased the last property read — and because the search
@@ -6260,6 +6361,7 @@ SUITE(cypher) {
RUN_TEST(cypher_issue873_distinct_order_skip_limit_dedupes_before_skip);
RUN_TEST(cypher_issue252_tointeger);
RUN_TEST(cypher_issue305_count_star_alias);
RUN_TEST(cypher_count_star_empty_input_returns_zero);
RUN_TEST(cypher_exec_where_eq);
RUN_TEST(cypher_exec_unlabeled_where_beyond_result_limit_issue1196);
RUN_TEST(cypher_exec_aggregate_sees_all_edges_beyond_expansion_cap_issue1196);