Address result columns by the name used in the query builder - #582
Open
Yaraslaut wants to merge 4 commits into
Open
Address result columns by the name used in the query builder#582Yaraslaut wants to merge 4 commits into
Yaraslaut wants to merge 4 commits into
Conversation
…builder
Reading a result set required tracking 1-based column indices by hand, so adding
a field to a projection silently shifted every index below it.
SqlSelectQueryBuilder now records the caller-given name of every projected column
as the projection is assembled, and SqlResultCursor resolves those names:
auto cursor = stmt.ExecuteDirect(stmt.Query("Table_A")
.Select()
.Field({ "Table_A", "foo" })
.Fields({ "a"sv, "b"sv }, "Table_B")
.All());
while (cursor.FetchRow())
auto const foo = cursor.GetColumn<int>("Table_A.foo");
GetNullableColumn and GetColumnOr take a name the same way.
The mapping comes from the builder rather than from ODBC result-set metadata,
because metadata cannot deliver table-qualified names portably: probing the three
supported drivers with the same join shows SQLite and PostgreSQL reporting
SQL_DESC_TABLE_NAME, while ODBC Driver 18 for SQL Server returns empty strings
under the library's default forward-only cursor (it only populates them for a
static, server-side cursor). Sourcing the names from the builder keeps the
behaviour identical on every backend and costs no round-trip.
Names match exactly as spelled: Field("foo") reads back as "foo",
Field({"T", "foo"}) as "T.foo", and an alias replaces the name of the projection
it follows. An un-aliased aggregate occupies an unnamed slot that holds its
position without being addressable. Queries with no usable mapping — raw SQL, or
a projection containing a wildcard whose column count is unknown at build time —
throw std::invalid_argument, as do unknown, ambiguous and empty names.
Closes #341
Yaraslaut
force-pushed
the
feat/341-named-column-access
branch
from
August 19, 2026 13:55
2327631 to
3e7b41d
Compare
Applying /code-review findings on this branch.
ExecuteBatchFetch() runs raw SQL but, unlike Prepare() and ExecuteDirect(),
left the previous query's projected-name mapping in place. A builder query
that recorded FirstName->1, Salary->2 followed by a raw fetch selecting the
columns in the other order kept resolving "FirstName" to column 1 -- which
was now Salary. Clear the mapping there too.
Count() discarded the projection's field names but kept
projectionHasWildcard set, so Select().Field("*").Count() raised the
"project the columns explicitly" diagnostic for a projection Count() had
already thrown away. Reset the flag with the names.
AdoptProjectedFieldNames() is the only way the mapping gets populated and
has no callers outside SqlStatement, yet it was public: user code could
install a mapping from an unrelated query and make every GetColumn<T>(name)
read the wrong column silently instead of throwing. Make it private.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
Codecov reported the two RecordProjectedFieldName() calls in FieldAs() as the only uncovered added lines: nothing in the suite calls either overload. FieldAs() is deprecated in favour of Field(...).As(...), but it still ships, and it reaches the projected-name table by a different route than As() does — it records the alias as it projects the column, where As() renames an entry that is already there. A deprecated overload that silently stopped registering its alias would break named access for every caller that has not migrated yet, which is exactly the kind of regression the deprecation period is supposed to avoid. Calling deprecated API is the point of the test, so -Wdeprecated-declarations (MSVC: C4996) is suppressed around that single test case rather than project-wide. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ccess
The only line this branch left uncovered was the projected-name registration in
`Fields(std::string_view, MoreFields&&...)`. Every named-access test projects through
a container overload (`Fields({ "a"sv, "b"sv })`), and no test anywhere calls the
variadic overload with a *single* field - so its zero-extra-arguments instantiation,
in which the fold over the remaining fields is discarded and the first field is the
only registration, was never generated.
The new test drives both shapes of the overload and reads the results back by name,
so the registration is asserted through the feature it exists for rather than by
merely instantiating the template.
Databases tested: sqlite3, mssql2022 (Docker), postgres (Docker 16.4) - full suite
green on all three under clang-debug (PEDANTIC + ASan + UBSan).
Co-Authored-By: Claude Opus 5 (1M context) <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 #341
What
Result columns of a builder-composed query can now be read by the name spelled in the builder, instead of by a 1-based index that shifts whenever the projection changes:
GetNullableColumn<T>(name)andGetColumnOr<T>(name, defaultValue)take a name the same way.Why the mapping comes from the builder, not from ODBC
The obvious implementation — ask the driver via
SQLDescribeCol/SQLColAttribute— cannot deliver the table-qualified names the issue asks for. Probing all three supported drivers with the same join (SELECT t1.x, t2.a, ... FROM t1 JOIN t2 ...):SQL Server only populates
SQL_DESC_TABLE_NAMEwhen the statement runs a static, server-side cursor; under the library's default forward-only cursor it returns empty strings. Going that route would have meant either an API that behaves differently per DBMS, or switching cursor type library-wide.Recording the names in
SqlSelectQueryBuilderas the projection is assembled avoids both: the mapping is identical on every backend and costs no round-trip. The trade-off is that named access is available only for builder-composed queries.Name rules
Names match exactly as spelled — no case folding, no implicit qualification:
Field("foo")"foo"Field({ "Table_A", "foo" })"Table_A.foo"Fields({ "a"sv, "b"sv }, "Table_B")"Table_B.a","Table_B.b"Field(...).As("total")/FieldAs(..., "total")"total"Field(Aggregate::Count("id"sv))std::invalid_argumentis thrown for a name that is unknown, ambiguous (projected twice), or empty, and for a query with no usable mapping: raw SQL, or a projection containing a wildcard, whose column count is unknown at build time. A statement reused for raw SQL drops the previous query's mapping rather than resolving stale names.Implementation
detail::ComposedQuerygainsprojectedFieldNames+projectionHasWildcard, exposed viaProjectedFieldNames()/ProjectionHasWildcard(). It already travels to the statement as the query object, so no new plumbing was needed between builder and statement.SqlNamedProjectionQueryObjectconcept letsPrepare/ExecuteDirectadopt the mapping when the query object carries one.SqlResultCursor— already the object the statement returns — resolves names by linear scan over a vector that is realistically under 20 entries. No hash map, no per-lookup allocation.Prepare(SqlQueryObject auto const&) &&overload could not have compiled if instantiated (it called the&-qualified string overload, which returnsvoid, from a function returningSqlStatement). It is now correct, since named queries take that path.Verification
Databases — full suite, run sequentially, lexical order:
Compilers —
clang-release(Clang 21, macOS) locally;clang-tidyrun against the changed translation units with theclang-debugcompile database, clean. GCC and the C++20-modules / C++26-reflection configurations are left to CI — they are not reachable from a local preset on this host.Coverage — 9 new builder-level test cases in
QueryBuilderTests.cppasserting the recorded name vector for every projection entry point with no database involved, and 8 end-to-end cases inSqlStatementDbTests.cppcovering bare / qualified / aliased reads,Prepare+Execute, nullable and default-valued reads, and every error path.Risk
std::string_view-convertible argument.std::stringper projected column at build time; the SQL text itself is unchanged. Lookup is a linear scan, only on the named path.SQLGetDatawith SQLSTATE 07009 and named access makes reordering easy to do inadvertently.🤖 Generated with Claude Code