Parse only the columns a query needs (#296)#304
Merged
Conversation
Queries now parse rows in two phases (issue #296): the columns the WHERE clause references are parsed for every candidate row, and the remaining projected (and, when sorting, ORDER BY) columns only once a row matches. The needed-ordinal sets are computed once at bind/prepare time by walking the bound expression tree (SqlColumnCollector); the same treatment applies to DbfQueryDataReader (sequential and index row sources, sync and async), DbfQuery<T>, and both COUNT(*) read-and-filter executors. DbfRecord tracks parsed ordinals with per-row version stamps. Accessing a value that was not parsed for the current row throws a clear InvalidOperationException instead of exposing the previous row's content through the reused value objects. The eager Read path is unaffected - the guard only activates for records the query engine puts in subset mode. Sort buffering also snapshots only the projected and ORDER BY ordinals instead of every column. On the 50,000-row benchmark table, full-scan queries drop 17-21% in time and 72-86% in allocations; selective scans on the 16-column tl_2019 file run 3.6x faster with 11x fewer allocations. Status scans and index-seek paths are unchanged. Benchmark infrastructure fix that this work surfaced: BenchmarkDotNet rebuilds the benchmarks project in a generated child project that does not inherit -p: MSBuild properties, so "-p:DbfDataReaderVersion=x.y.z" silently benchmarked the csproj default. The README now documents the environment-variable form (which reaches every build), and each benchmark prints the informational version of the DbfDataReader assembly it actually loaded. The published benchmarks.md tables were re-verified against correctly-pinned packages and stand as recorded. Closes #296 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Row evaluation collapses to a tri-state EvaluateRow helper shared by the four query loops, the count executor splits by row source, and QueryPlan's parse sets are properties rather than extra constructor parameters. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.



Implements #296: the query engine now parses only the columns a query references, in two phases — the WHERE clause's columns for every candidate row, and the remaining projected (plus, when sorting, ORDER BY) columns only once a row matches. For selective queries over wide tables, most rows never parse most columns.
How it works
SqlColumnCollectorwalks the bound expression tree once at bind/prepare time to collect filter ordinals; the post-filter set is (projection ∪ sort keys) − filter columns.DbfRecordgains an internal subset mode:TryParseValues(int[] ordinals)parses just those values (additively within a row), and per-row version stamps track what's valid. Accessing an unparsed ordinal throws a clearInvalidOperationExceptioninstead of exposing the previous row's content through the reused value objects — the stale-data hazard the issue called out. The eagerReadpath is untouched; the guard only activates for records the engine puts in subset mode, costing one null check.DbfQueryDataReader(sequential + index row sources, sync + async, and the ORDER BY buffer now snapshots only needed ordinals),DbfQuery<T>(Execute, async enumeration,Count), and bothCOUNT(*)read-and-filter executors.Measurements
BenchmarkDotNet, 50,000-row generated table (4 columns), version-pinned packages (before = main with #303):
On the 16-column
tl_2019_01_placefixture, a selective scan projecting one column runs 3.6× faster with 11× fewer allocations (1,737 → 483 µs, 341 → 31 KB), and filteredCOUNT(*)is 1.9× faster.Benchmark infrastructure fix this surfaced
While validating, the before/after BDN runs produced byte-identical allocation numbers — because BenchmarkDotNet rebuilds the benchmarks project in a generated child project that does not inherit
-p:MSBuild properties.-p:DbfDataReaderVersion=x.y.zhas therefore been silently benchmarking the csproj default all along. Fixed by documenting the environment-variable form (DbfDataReaderVersion=x.y.z dotnet run …), which reaches every build, and by making each benchmark print the informational version of the DbfDataReader assembly it actually loaded (// DbfDataReader under benchmark: 1.1.0+41ded65…) so a wrong package is impossible to miss. The published benchmarks.md tables were re-verified against correctly-pinned packages: true 1.1.0 measures 643.8 µs / 704.4 KB vs the recorded 611.1 µs / 704.41 KB — within noise, so the recorded data stands.Tests
501 tests pass (21 new): record-level guard tests (unparsed access throws, parsing is additive, stamps reset on row advance, full reads mark everything parsed), and full-parse-oracle differentials for every path — projection/filter on disjoint columns,
SELECT *with filter, ORDER BY on a non-projected column, LIMIT, COUNT, async readers, memo tables, the typed builder (sync/async/Count), and index-vs-scan equality on the CDX fixtures.Closes #296
🤖 Generated with Claude Code