Skip to content

Parse only the columns a query needs (#296)#304

Merged
chrisrichards merged 3 commits into
mainfrom
feature/column-subset-parsing
Jul 6, 2026
Merged

Parse only the columns a query needs (#296)#304
chrisrichards merged 3 commits into
mainfrom
feature/column-subset-parsing

Conversation

@chrisrichards

Copy link
Copy Markdown
Member

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

  • SqlColumnCollector walks the bound expression tree once at bind/prepare time to collect filter ordinals; the post-filter set is (projection ∪ sort keys) − filter columns.
  • DbfRecord gains 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 clear InvalidOperationException instead of exposing the previous row's content through the reused value objects — the stale-data hazard the issue called out. The eager Read path is untouched; the guard only activates for records the engine puts in subset mode, costing one null check.
  • Applied everywhere rows are filtered or projected: DbfQueryDataReader (sequential + index row sources, sync + async, and the ORDER BY buffer now snapshots only needed ordinals), DbfQuery<T> (Execute, async enumeration, Count), and both COUNT(*) read-and-filter executors.
  • Unreferenced memo columns are never parsed, so selective queries on memo tables also skip memo-file I/O.

Measurements

BenchmarkDotNet, 50,000-row generated table (4 columns), version-pinned packages (before = main with #303):

Scenario Before After
equality seek, full scan 21,634 µs / 6,262 KB 17,328 µs / 1,184 KB
character seek, full scan 21,584 µs / 7,048 KB 17,944 µs / 1,977 KB
range scan, full scan 22,419 µs / 8,218 KB 17,763 µs / 1,193 KB
count(*) filtered, full scan 22,374 µs / 8,215 KB 17,767 µs / 1,184 KB
top 10 order by desc, scan + sort 51,760 µs / 54,124 KB 45,831 µs / 53,467 KB
count(*) status scan / index paths unchanged unchanged

On the 16-column tl_2019_01_place fixture, a selective scan projecting one column runs 3.6× faster with 11× fewer allocations (1,737 → 483 µs, 341 → 31 KB), and filtered COUNT(*) 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.z has 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

chrisrichards and others added 3 commits July 6, 2026 18:26
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>
@sonarqubecloud

sonarqubecloud Bot commented Jul 6, 2026

Copy link
Copy Markdown

@chrisrichards chrisrichards merged commit e5fda7f into main Jul 6, 2026
3 checks passed
@chrisrichards chrisrichards deleted the feature/column-subset-parsing branch July 6, 2026 18:34
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.

Only parse the columns a query needs (column-subset optimisation)

1 participant