Skip to content

Address result columns by the name used in the query builder - #582

Open
Yaraslaut wants to merge 4 commits into
masterfrom
feat/341-named-column-access
Open

Address result columns by the name used in the query builder#582
Yaraslaut wants to merge 4 commits into
masterfrom
feat/341-named-column-access

Conversation

@Yaraslaut

Copy link
Copy Markdown
Member

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:

auto cursor = stmt.ExecuteDirect(stmt.Query("Table_A")
                                     .Select()
                                     .Field(SqlQualifiedTableColumnName { "Table_A", "foo" })
                                     .Fields({ "a"sv, "b"sv }, "Table_B")
                                     .Field(Aggregate::Count("id"sv)).As("total"sv)
                                     .LeftOuterJoin("Table_B", "id"sv, "that_id"sv)
                                     .All());

while (cursor.FetchRow())
{
    auto const foo = cursor.GetColumn<int>("Table_A.foo");
    auto const a = cursor.GetColumn<int>("Table_B.a");
    auto const total = cursor.GetColumn<int>("total");
}

GetNullableColumn<T>(name) and GetColumnOr<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 ...):

Driver Column name Table name
SQLite3 ODBC reported reported
PostgreSQL Unicode reported reported
ODBC Driver 18 for SQL Server reported empty

SQL Server only populates SQL_DESC_TABLE_NAME when 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 SqlSelectQueryBuilder as 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:

Builder call Name to read it back by
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)) not addressable — occupies an unnamed slot that holds its position

std::invalid_argument is 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::ComposedQuery gains projectedFieldNames + projectionHasWildcard, exposed via ProjectedFieldNames() / ProjectionHasWildcard(). It already travels to the statement as the query object, so no new plumbing was needed between builder and statement.
  • A new SqlNamedProjectionQueryObject concept lets Prepare / ExecuteDirect adopt 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.
  • Incidental fix: the rvalue Prepare(SqlQueryObject auto const&) && overload could not have compiled if instantiated (it called the &-qualified string overload, which returns void, from a function returning SqlStatement). It is now correct, since named queries take that path.

Verification

Databases — full suite, run sequentially, lexical order:

Database Result
sqlite3 (SQLite 3.46.1) 1409 passed, 1 skipped
postgres (Docker, PostgreSQL 16.4) 1408 passed, 2 skipped
mssql2022 (Docker, SQL Server 16.00.4265) 1407 passed, 3 skipped

Compilersclang-release (Clang 21, macOS) locally; clang-tidy run against the changed translation units with the clang-debug compile 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.cpp asserting the recorded name vector for every projection entry point with no database involved, and 8 end-to-end cases in SqlStatementDbTests.cpp covering bare / qualified / aliased reads, Prepare + Execute, nullable and default-valued reads, and every error path.

Risk

  • Per-DBMS: none by construction — the mapping never consults the driver, so it cannot vary by backend.
  • Behaviour: purely additive. Existing index-based overloads are untouched; the new overloads are selected only for a std::string_view-convertible argument.
  • Performance: one std::string per projected column at build time; the SQL text itself is unchanged. Lookup is a linear scan, only on the named path.
  • Ordering caveat (documented): reads must still ascend in column order, since SQL Server rejects out-of-order SQLGetData with SQLSTATE 07009 and named access makes reordering easy to do inadvertently.

🤖 Generated with Claude Code

@Yaraslaut
Yaraslaut requested a review from a team as a code owner August 19, 2026 12:39
@github-actions github-actions Bot added documentation Improvements or additions to documentation Query Builder tests Core API labels Aug 19, 2026
…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
Yaraslaut force-pushed the feat/341-named-column-access branch from 2327631 to 3e7b41d Compare August 19, 2026 13:55
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

codecov Bot commented Aug 20, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.76543% with 1 line in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/Lightweight/SqlQuery/Select.hpp 50.00% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

Yaraslaut and others added 2 commits August 20, 2026 17:38
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Core API documentation Improvements or additions to documentation Query Builder tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Better API to access columns

1 participant