Support COUNT(*) with index and status-scan fast paths#299
Merged
Conversation
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>
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



Closes #293.
Summary
SELECT COUNT(*)executes without materializing rows, picking the cheapest strategy the planner can prove safe — andExplainPlan()tells you which one ran:The strategies
DbfRecordgains an internalReadRaw(theRead= 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.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);LIKEalways needs its pattern re-applied; provably-empty candidates are exact by definition (where CALL_ID = 1.5counts 0 without opening the table).SkipDeletedRecords=truethe index result can include deleted rows, so each matched record's status byte is checked — still no value parsing.DbfQuery<T>.Count()uses the same fast paths (index-only when deleted rows are included, status checks otherwise, status-only scan when filterless), honouringTakeandIncludeDeleted.Dialect details
COUNTis not a reserved word — it acts as a function only when followed by(, so columns namedcountkeep working (tested).count(column), additional select-list items, andORDER BYwithCOUNT(*)are rejected with position-carrying errors. The result is a one-row reader with anintcolumn namedcount, soExecuteScalarand Dapper'sExecuteScalar<int>compose naturally;TOP 0yields 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
ExplainPlanassertions and oracle-checked counts (status scan with both deleted modes, index-only, status-checks branch, provably-empty, residual, inexactLIKE, character equality, indexes-disabled differential), reader shape (schema, typed getters, single row),TOPinteraction, parameterized counts, builder fast paths (filterless/exact/residual/Take/IncludeDeleted/WithoutIndexes), and a DapperExecuteScalar<int>line. Full suite: 433 passed, 1 pre-existing skip, zero warnings on both TFMs.🤖 Generated with Claude Code