Skip to content

Fix stale values in ungrouped aggregate queries with no matching rows#5578

Open
vimalprakashts wants to merge 2 commits intotursodatabase:mainfrom
vimalprakashts:fix/ungrouped-aggregate-nullrow
Open

Fix stale values in ungrouped aggregate queries with no matching rows#5578
vimalprakashts wants to merge 2 commits intotursodatabase:mainfrom
vimalprakashts:fix/ungrouped-aggregate-nullrow

Conversation

@vimalprakashts
Copy link
Contributor

@vimalprakashts vimalprakashts commented Feb 24, 2026

Summary

  • Fix ungrouped aggregate queries returning stale non-aggregate column values instead of NULL when no rows match the WHERE clause
  • Emit NullRow for all btree table and index cursors in the empty-result path
  • Add null-flag check to IdxRowId so it returns NULL (matching Column behavior)

Bug

CREATE TABLE t(x INTEGER, y TEXT);
INSERT INTO t VALUES(1, 'a'), (2, 'b');
SELECT x, COUNT(*) FROM t WHERE 0;

Expected (SQLite): NULL|0
Actual (Turso before fix): 2|0 — leaks the last scanned row's value

The same issue affects index-only scans (IdxRowId returning a stale rowid).

Root Cause

The ungrouped aggregation codegen has a "no rows" fallback path that evaluates non-aggregate columns when the scan loop never executed. This path nulled coroutine output registers (for CTEs/subqueries) but did not issue NullRow on btree/index cursors. The Column instruction already checks the null flag, but IdxRowId did not — so index-based plans also leaked stale rowids.

Fix

  1. core/translate/aggregation.rs: In the no-rows fallback, emit NullRow for every btree table cursor and index cursor (looked up via resolve_cursor_id_safe to handle covering-index cases where the table cursor isn't allocated).
  2. core/vdbe/execute.rs: Add a null-flag check at the top of op_idx_row_id — if the cursor is in NullRow state, return NULL immediately instead of reading a stale rowid.
  3. core/types.rs: Add Cursor::get_null_flag() accessor.

Test Plan

  • New sqltest: testing/runner/tests/ungrouped-aggregate-nullrow.sqltest — covers table scans, index scans, multi-table joins, CTEs, WHERE-false and WHERE-no-match variants
  • make -C testing/runner run-rust passes
  • cargo test passes
  • cargo clippy --workspace --all-features --all-targets -- --deny=warnings passes

Copy link

@turso-bot turso-bot bot left a comment

Choose a reason for hiding this comment

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

Please review @PThorpe92

When an ungrouped aggregate query (e.g. SELECT x, COUNT(*) FROM t WHERE 0)
has no matching rows, non-aggregate columns should return NULL. Previously,
btree cursors still pointed at the last scanned row, so Column and IdxRowId
instructions returned stale values instead of NULL.

Root cause: the NullRow fixup path only nulled coroutine output registers
(for CTEs/subqueries) but did not issue NullRow on btree/index cursors.
IdxRowId also lacked a null-flag check, so it returned the last rowid.

Fix:
- Emit NullRow for all btree table and index cursors in the no-rows path
- Add null-flag check to IdxRowId so it returns NULL like Column does
- Add Cursor::get_null_flag() accessor
@vimalprakashts vimalprakashts force-pushed the fix/ungrouped-aggregate-nullrow branch from a256165 to b11a2b9 Compare February 24, 2026 16:56
JavaScript's Number.isInteger(30.0) returns true, so the JS test runner
formats SUM(real_col) = 30.0 as "30" instead of "30.0". Add an
`expect @js` block to account for this known limitation.
@vimalprakashts vimalprakashts force-pushed the fix/ungrouped-aggregate-nullrow branch from 93cb91d to 4bac7f0 Compare February 24, 2026 17:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant