Skip to content

Support COUNT(*) with index and status-scan fast paths#299

Merged
chrisrichards merged 2 commits into
mainfrom
feature/count-fast-paths
Jul 6, 2026
Merged

Support COUNT(*) with index and status-scan fast paths#299
chrisrichards merged 2 commits into
mainfrom
feature/count-fast-paths

Conversation

@chrisrichards

Copy link
Copy Markdown
Member

Closes #293.

Summary

SELECT COUNT(*) executes without materializing rows, picking the cheapest strategy the planner can prove safe — and ExplainPlan() tells you which one ran:

select count(*) from dbase_03.dbf                              → count via record status scan
select count(*) from calls.dbf where CONTACT_ID = 1           → index seek (=) on tag 'CONTACT_ID' (count from index only)
  …same with SkipDeletedRecords=true                          → … (count with record status checks)
select count(*) from calls.dbf where CONTACT_ID = 1 and CALL_ID > 2 → … (count by reading rows)

The strategies

  • Status-only scan (no WHERE): records are read but value parsing is skipped entirelyDbfRecord gains an internal ReadRaw (the Read = raw + parse split also lays groundwork for Only parse the columns a query needs (column-subset optimisation) #296). The header record count is deliberately not trusted, per the long-standing decision from Add Seek and RecordIndex for random record access #283.
  • Index-only count (zero table I/O): when the WHERE is a single conjunct served by an exact index candidate, the count is just the search result size. Exactness is a new planner concept (QueryAccessPlan.CoversWhereExactly): character and integer candidates are exact; double/date candidates are not (decimal→double conversion can collapse boundaries — the same reasoning that made their strict bounds inclusive in Support integer, numeric, double and date CDX index keys #298); LIKE always needs its pattern re-applied; provably-empty candidates are exact by definition (where CALL_ID = 1.5 counts 0 without opening the table).
  • Index + status checks: with SkipDeletedRecords=true the index result can include deleted rows, so each matched record's status byte is checked — still no value parsing.
  • Row counting: residual filters or inexact candidates fall back to reading and filtering rows, just never projecting them.

DbfQuery<T>.Count() uses the same fast paths (index-only when deleted rows are included, status checks otherwise, status-only scan when filterless), honouring Take and IncludeDeleted.

Dialect details

COUNT is not a reserved word — it acts as a function only when followed by (, so columns named count keep working (tested). count(column), additional select-list items, and ORDER BY with COUNT(*) are rejected with position-carrying errors. The result is a one-row reader with an int column named count, so ExecuteScalar and Dapper's ExecuteScalar<int> compose naturally; TOP 0 yields zero result rows per SQL semantics.

Test plan

17 new tests: parser acceptance/rejection (including the count-as-column-name case), the full strategy matrix with ExplainPlan assertions and oracle-checked counts (status scan with both deleted modes, index-only, status-checks branch, provably-empty, residual, inexact LIKE, character equality, indexes-disabled differential), reader shape (schema, typed getters, single row), TOP interaction, parameterized counts, builder fast paths (filterless/exact/residual/Take/IncludeDeleted/WithoutIndexes), and a Dapper ExecuteScalar<int> line. Full suite: 433 passed, 1 pre-existing skip, zero warnings on both TFMs.

🤖 Generated with Claude Code

chrisrichards and others added 2 commits July 6, 2026 16:07
Closes #293.

SELECT COUNT(*) [FROM t WHERE ...] executes without materializing
rows, choosing the cheapest safe strategy. Without a WHERE clause,
records are scanned reading status bytes only - DbfRecord gains an
internal ReadRaw that fills the buffer and sets IsDeleted and
RecordIndex without parsing column values, and the header record
count is deliberately not trusted. When the planner reports that an
index search covers the WHERE exactly (new
QueryAccessPlan.CoversWhereExactly: a single conjunct served by an
exact candidate - character and integer keys are exact, double and
date keys are not because decimal-to-double conversion can collapse
boundaries, and LIKE always needs its pattern re-applied), the count
is the size of the search result with no table I/O at all, or with
per-record status checks when deleted rows must be skipped, since
index entries include them. Everything else counts by reading and
filtering rows without projecting them.

COUNT is not a reserved word - it only acts as a function when
followed by a parenthesis, so columns named count keep working.
COUNT(column), additional select-list items and ORDER BY with
COUNT(*) are rejected with clear errors. The result is a single-row,
single-column reader (int column named "count"), so ExecuteScalar
and Dapper's ExecuteScalar<int> work naturally, and ExplainPlan
describes the chosen counting strategy. DbfQuery<T>.Count() uses the
same fast paths, honouring Take and IncludeDeleted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…arsing extracted

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@sonarqubecloud

sonarqubecloud Bot commented Jul 6, 2026

Copy link
Copy Markdown

@chrisrichards chrisrichards merged commit 0542af6 into main Jul 6, 2026
3 checks passed
@chrisrichards chrisrichards deleted the feature/count-fast-paths branch July 6, 2026 15:13
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.

Support COUNT(*) with index and header fast paths

1 participant