Skip to content

fix(cypher): classify aggregation columns by is_aggregate_func, not a bare func check#1221

Open
AmirF194 wants to merge 1 commit into
DeusData:mainfrom
AmirF194:fix/1111-aggregate-scalar-func-misclassification
Open

fix(cypher): classify aggregation columns by is_aggregate_func, not a bare func check#1221
AmirF194 wants to merge 1 commit into
DeusData:mainfrom
AmirF194:fix/1111-aggregate-scalar-func-misclassification

Conversation

@AmirF194

Copy link
Copy Markdown

What does this PR do?

Fixes the type(r)/count(*) half of #1111: RETURN/WITH aggregation misroutes non-aggregate functions into the aggregate-value branch whenever the query also carries an aggregate column, silently substituting the row count for the actual value.

MATCH ()-[r]->() RETURN type(r) AS tipo, count(*) AS n ORDER BY n DESC
# before: {"tipo":"176178","n":"176178"}   (row count in both columns)
# after:  {"tipo":"CALLS","n":176178}

Root cause

ret_agg_build_key/ret_agg_emit_row (RETURN) and with_agg_build_key/with_agg_find_or_create/with_agg_accumulate (WITH) classify each projected column with a bare if (item->func) check to decide "is this the aggregate column or the group-key column". That check is wrong: item->func is set for any function call, aggregate or not, so type(r), labels(n), id(n), keys(n), and properties(n) read as truthy and land in the aggregate branch next to real aggregates like count(*). There they are formatted by format_agg_value's default case, which emits the accumulated row count, not the value the query asked for.

The correct predicate already exists and is already used correctly one level up, where execute_return_clause/execute_with_clause decide whether a query needs aggregation at all: is_aggregate_func(), which checks the function name against the fixed aggregate set (COUNT/SUM/AVG/MIN/MAX/COLLECT). The per-column classification inside the aggregation-execution path never adopted it.

The fix

Swap the five item->func truthy checks for is_aggregate_func(item->func). A group-key column that carries a non-aggregate function now needs to actually evaluate that function instead of reading a raw variable/property, so its value comes from the existing project_item() helper (already used by the non-aggregated RETURN/WITH paths) rather than binding_get_virtual().

Verification

Docker (ubuntu:24.04, gcc, ASan+UBSan build):

  • Reverted only the src/cypher/cypher.c hunk with the two new tests kept: both fail exactly as reported (type(r) and count(*) both return the row count).
  • Restored the fix: both new tests pass; full cypher suite is 162/162.
  • scripts/lint.sh --ci CLANG_FORMAT=clang-format-20 (cppcheck 2.20.0 + clang-format-20, matching _lint.yml): clean.
  • scripts/security-audit.sh: passed, no new findings touching the changed files.
  • Full unit suite (make -f Makefile.cbm test-par): 6770 passed, 12 failed, 4 skipped. The 12 failures (in subprocess, cli, mcp, index_supervisor, all process-tree/daemon-lifecycle tests unrelated to cypher.c) reproduce identically on unmodified main in the same container, so they predate this change.

Not verified: the other claim in #1111, an inline property map in a MATCH pattern silently matching 0 rows, does not reproduce on current HEAD and is unrelated to this fix. Leaving that open for you to split or close separately.

Fixes #1111

… bare func check

RETURN/WITH aggregation classified each projected column with a bare
`if (item->func)` check to decide group-key vs. aggregate value. That
check is true for any function call, so type()/labels()/id()/keys()/
properties() were routed into the aggregate branch alongside real
aggregates and formatted via format_agg_value's default case, which
emits the row count instead of the function's actual value.

Swap the five call sites (ret_agg_build_key, ret_agg_emit_row,
with_agg_build_key, with_agg_find_or_create, with_agg_accumulate) to
is_aggregate_func(), the predicate already used correctly one level up
to decide whether a query needs aggregation at all. Group-key columns
carrying a non-aggregate function now project through the existing
project_item() helper instead of binding_get_virtual(), since they
need to evaluate the function rather than read a raw property.

Fixes DeusData#1111 (the type(r)/count(*) half; the inline-property-map half
of that issue does not reproduce on current HEAD)

Signed-off-by: Amir Fathi <amirfathi.me@gmail.com>
@AmirF194
AmirF194 requested a review from DeusData as a code owner July 23, 2026 04:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Inline property map in MATCH pattern silently matches nothing — should error if unsupported

1 participant