diff --git a/src/cypher/cypher.c b/src/cypher/cypher.c index c86be43bf..fe11f06fb 100644 --- a/src/cypher/cypher.c +++ b/src/cypher/cypher.c @@ -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); diff --git a/tests/test_cypher.c b/tests/test_cypher.c index ec3afbfc2..b3b28b633 100644 --- a/tests/test_cypher.c +++ b/tests/test_cypher.c @@ -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);