From e5ce401bb70d876325200d0164c9ddf9dff817dd Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Mon, 22 Jun 2026 10:50:02 +0200 Subject: [PATCH 1/6] [DataMapper] Add ODBC-native row-wise array fetch for fast bulk retrieval MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Query().All()/Range() (and two-record JOIN tuples) now bind result columns row-wise directly into the caller's std::vector storage and pull whole row blocks per SQLFetchScroll round-trip, instead of one SQLFetch per row. This is the read-side mirror of the CreateAll/UpdateAll batch-write path and collapses ODBC round-trips from N to ~ceil(N/depth) — the win on high-latency links where receiving e.g. 1000 rows previously cost 1000 fetches. The path is transparent and gated: a record qualifies when every result column is row-bindable (the write-side SqlRowBindableColumn set: primitives, date/ time/datetime, numeric, char fixed-capacity strings, and non-numeric optionals of those) and the driver supports row-array fetching. Anything else (growable strings/binary, GUID, variant) falls back to the unchanged per-row path with byte-identical results. Values land in place (zero-copy); nullable columns use an over-allocated row-strided NULL indicator, and optionals are pre-engaged and reset on NULL. Char fixed strings bind SQL_C_CHAR inline with a per-row length/trim fixup; on PostgreSQL (psqlODBC transcodes SQL_C_CHAR through the client codepage) records carrying one fall back to the per-row wide path, gated by the new SqlConnection::RoundTripsNarrowTextByteExact capability so the server-type decision stays on the connection. - SqlStatement::FetchAllRowWise + BindRowWiseValue/FinalizeRowWiseOutputColumn: SQL_ATTR_ROW_BIND_TYPE = sizeof(Record), grow-and-rebind per block, memory- budget-clamped depth, and a Finally guard restoring single-row state on every exit (incl. exceptions). - SqlConnection::SupportsNativeRowArrayFetch / RoundTripsNarrowTextByteExact. - DataMapper eligibility (CanRowWiseFetchRecord/CanRowWiseFetchTuple + narrow- text carve-out) and the ReadResults wiring for both single and tuple results. Tested against sqlite3, mssql2022 and postgres: new RowWiseFetchTests cover fixed/nullable/temporal/fixed-string types, NULL/empty/full-capacity values, multi-block boundaries, empty results, Where/Range, statement reuse and the std::string fallback, asserting via a block-fetch-counting logger that the fast path actually ran (and that fixed-string records fall back on PostgreSQL). Signed-off-by: Christian Parpart --- src/Lightweight/DataMapper/DataMapper.hpp | 238 +++++++++++++ src/Lightweight/SqlConnection.hpp | 72 ++++ src/Lightweight/SqlStatement.hpp | 254 ++++++++++++++ src/tests/CMakeLists.txt | 1 + src/tests/DataMapper/RowWiseFetchTests.cpp | 371 +++++++++++++++++++++ 5 files changed, 936 insertions(+) create mode 100644 src/tests/DataMapper/RowWiseFetchTests.cpp diff --git a/src/Lightweight/DataMapper/DataMapper.hpp b/src/Lightweight/DataMapper/DataMapper.hpp index 3b20e3e19..552d5aaef 100644 --- a/src/Lightweight/DataMapper/DataMapper.hpp +++ b/src/Lightweight/DataMapper/DataMapper.hpp @@ -744,6 +744,220 @@ namespace detail BindAllOutputColumnsWithOffset(reader, record, 1); } + /// @brief Requested rows per SQLFetchScroll round-trip for the native row-wise fetch fast path. The + /// statement clamps this to a memory budget, so it is an upper bound, not a guarantee. + constexpr std::size_t kDefaultRowArrayFetchDepth = 1024; + + /// @brief Mutable-reference output accessor for member @p I that is a Field/BelongsTo: yields the + /// field's mutable value so the row-wise fetch path binds the result column in place. The read-side + /// counterpart of @ref FieldValueAccessor. + template + struct MutableFieldValueAccessor + { + template + decltype(auto) operator()(Record& record) const + { + return GetRecordMemberAt(record).MutableValue(); + } + }; + + /// @brief The mutable value type bound for member @p FieldType on the row-wise fetch path (the type + /// the result column materializes into). + template + using RowWiseColumnValueType = std::remove_cvref_t().MutableValue())>; + + /// @return Whether @p FieldType maps to a result column on the bound-output path (Field, BelongsTo, or + /// a directly-bindable member) — mirrors the classification in @ref BindAllOutputColumnsWithOffset. + template + constexpr bool RowWiseIsColumn() + { + return IsField || IsBelongsTo || SqlOutputColumnBinder; + } + + /// @return Whether @p FieldType is acceptable on the row-wise fetch path: either it is not a result + /// column (a relation member, which is not bound) or it is a column whose value type is + /// @ref SqlRowWiseFetchableColumn. Directly-bindable non-Field members are conservatively rejected + /// (their value would need a separate accessor shape) so such records fall back to the per-row path. + template + constexpr bool RowWiseColumnAcceptable() + { + if constexpr (IsField || IsBelongsTo) + return SqlRowWiseFetchableColumn>; + else if constexpr (SqlOutputColumnBinder) + return false; + else + return true; // relation / non-column member: not bound, imposes no constraint + } + + template + constexpr bool CanRowWiseFetchRecordImpl(std::index_sequence) + { + // The row-strided indicator slots are addressed at i * sizeof(Record); they must stay SQLLEN + // aligned, so sizeof(Record) must be a multiple of alignof(SQLLEN) (mirrors the write-side + // indicatorAlignmentSatisfied precondition). + return (sizeof(Record) % alignof(SQLLEN) == 0) && (RowWiseColumnAcceptable>() && ...) + && (RowWiseIsColumn>() || ...); + } + + /// @brief Whether @p Record can be materialized via the native row-wise array-fetch fast path: every + /// result column is a Field/BelongsTo of a @ref SqlRowWiseFetchableColumn type, there is at least one + /// column, and the record size keeps the row-strided indicators aligned. Records that fail this fall + /// back to the per-row @c SQLFetch path, with identical results. + template + constexpr bool CanRowWiseFetchRecord() + { + return CanRowWiseFetchRecordImpl(std::make_index_sequence> {}); + } + + /// Returns a one-element accessor tuple for member @p I when it is a bound result column, else an empty + /// tuple — flattened via tuple_cat so the accessor pack matches the bound column set and order exactly. + template + auto MakeOutputColumnAccessor() + { + using FieldType = RecordMemberTypeOf; + if constexpr (IsField || IsBelongsTo) + return std::tuple> {}; + else + return std::tuple<> {}; + } + + /// @brief Materializes the whole result set into @p records via @ref SqlStatement::FetchAllRowWise, + /// building one mutable value accessor per bound result column (same set and order as + /// @ref BindAllOutputColumnsWithOffset). Precondition: @ref CanRowWiseFetchRecord(). + template + void ReadAllRowWise(SqlResultCursor& reader, std::vector* records) + { + [&](std::index_sequence) { + std::apply( + [&](auto const&... accessors) { + reader.FetchAllRowWise(*records, kDefaultRowArrayFetchDepth, accessors...); + }, + std::tuple_cat(MakeOutputColumnAccessor()...)); + }(std::make_index_sequence> {}); + } + + /// @return Whether @p FieldType is a result column whose value is a char fixed-capacity string (or a + /// @c std::optional of one). Such columns are array-bound narrow (SQL_C_CHAR), which only round-trips + /// byte-exact where @ref SqlConnection::RoundTripsNarrowTextByteExact holds. + template + constexpr bool ColumnIsNarrowFixedString() + { + if constexpr (IsField || IsBelongsTo) + { + using V = RowWiseColumnValueType; + if constexpr (SqlIsStdOptional) + return IsSqlFixedString; + else + return IsSqlFixedString; + } + else + return false; + } + + template + constexpr bool RecordHasNarrowFixedStringColumnImpl(std::index_sequence) + { + return (ColumnIsNarrowFixedString>() || ...); + } + + /// @brief Whether @p Record has any char fixed-capacity-string result column. Such records take the + /// row-wise fetch fast path only on backends that round-trip narrow text byte-exact; elsewhere they + /// fall back to the per-row (wide) path. See @ref SqlConnection::RoundTripsNarrowTextByteExact. + template + constexpr bool RecordHasNarrowFixedStringColumn() + { + return RecordHasNarrowFixedStringColumnImpl(std::make_index_sequence> {}); + } + + /// @brief Whether @p Record may use the row-wise fetch fast path on @p serverType: it is row-wise + /// fetchable, the driver supports row-array fetch, and any narrow fixed-string column round-trips + /// byte-exact there. Single runtime gate composed from connection capabilities + the compile-time + /// record shape, so business logic never branches on the server type directly. + template + bool CanRowWiseFetchOn(SqlServerType serverType) + { + if constexpr (!CanRowWiseFetchRecord()) + return false; + else + return SqlConnection::SupportsNativeRowArrayFetch(serverType) + && (!RecordHasNarrowFixedStringColumn() + || SqlConnection::RoundTripsNarrowTextByteExact(serverType)); + } + + // --- Two-record tuple (JOIN) fast path ---------------------------------------------------------- + + /// @brief Mutable-reference output accessor for member @p I of the @p TupleIndex-th sub-record of a + /// @c std::tuple result row; yields that field's mutable value so a JOIN result binds in place. + template + struct MutableTupleFieldAccessor + { + template + decltype(auto) operator()(TupleType& row) const + { + return GetRecordMemberAt(std::get(row)).MutableValue(); + } + }; + + template + constexpr bool CanRowWiseFetchTupleImpl(std::index_sequence, std::index_sequence) + { + return (sizeof(std::tuple) % alignof(SQLLEN) == 0) + && (RowWiseColumnAcceptable>() && ...) + && (RowWiseColumnAcceptable>() && ...) + && ((RowWiseIsColumn>() || ...) + || (RowWiseIsColumn>() || ...)); + } + + /// @brief Whether a @c std::tuple JOIN row can be materialized via the row-wise fetch + /// fast path: both sub-records' columns are row-bindable and the combined row size keeps the + /// row-strided indicators aligned. + template + constexpr bool CanRowWiseFetchTuple() + { + return CanRowWiseFetchTupleImpl(std::make_index_sequence> {}, + std::make_index_sequence> {}); + } + + /// @brief Whether a @c std::tuple JOIN row may use the row-wise fetch fast path on + /// @p serverType (row-wise fetchable + driver supports row-array fetch + any narrow fixed-string + /// column round-trips byte-exact there). The tuple counterpart of @ref CanRowWiseFetchOn. + template + bool CanRowWiseFetchTupleOn(SqlServerType serverType) + { + if constexpr (!CanRowWiseFetchTuple()) + return false; + else + return SqlConnection::SupportsNativeRowArrayFetch(serverType) + && ((!RecordHasNarrowFixedStringColumn() && !RecordHasNarrowFixedStringColumn()) + || SqlConnection::RoundTripsNarrowTextByteExact(serverType)); + } + + /// Accessor tuple for member @p I of the @p TupleIndex-th sub-record, or empty for non-columns. + template + auto MakeTupleColumnAccessor() + { + using FieldType = RecordMemberTypeOf; + if constexpr (IsField || IsBelongsTo) + return std::tuple> {}; + else + return std::tuple<> {}; + } + + /// @brief Materializes a two-record JOIN result set into @p records via row-wise array fetch. The + /// accessor pack is First's columns followed by Second's, matching the column order of + /// @ref BindAllOutputColumnsWithOffset's offset scheme. Precondition: @ref CanRowWiseFetchTuple. + template + void ReadAllRowWiseTuple(SqlResultCursor& reader, std::vector>* records) + { + [&](std::index_sequence, std::index_sequence) { + std::apply( + [&](auto const&... accessors) { + reader.FetchAllRowWise(*records, kDefaultRowArrayFetchDepth, accessors...); + }, + std::tuple_cat(MakeTupleColumnAccessor<0, Fs, First>()..., MakeTupleColumnAccessor<1, Ss, Second>()...)); + }(std::make_index_sequence> {}, std::make_index_sequence> {}); + } + // when we iterate over all columns using element mask // indexes of the mask corresponds to the indexe of the field // inside the structure, not inside the SQL result set @@ -1268,6 +1482,19 @@ void SqlAllFieldsQueryBuilder::ReadResults(SqlS SqlResultCursor reader, std::vector* records) { + // Fast path: when every result column is a fixed-width row-bindable field and the driver honours + // native row-array fetching, materialize the whole result set in row blocks (one SQLFetchScroll per + // block) directly into records, instead of one SQLFetch round-trip per row. Results are identical to + // the per-row path below; this only collapses ODBC round-trips (the win on high-latency links). + if constexpr (detail::CanRowWiseFetchRecord()) + { + if (detail::CanRowWiseFetchOn(sqlServerType)) + { + detail::ReadAllRowWise(reader, records); + return; + } + } + while (true) { Record& record = records->emplace_back(); @@ -1293,6 +1520,17 @@ template , QueryOptions, Execution>::ReadResults( SqlServerType sqlServerType, SqlResultCursor reader, std::vector* records) { + // Fast path: a JOIN row of two row-bindable records is bound row-wise over the tuple and fetched in + // blocks (one SQLFetchScroll per block) instead of one SQLFetch per row. Identical results. + if constexpr (detail::CanRowWiseFetchTuple()) + { + if (detail::CanRowWiseFetchTupleOn(sqlServerType)) + { + detail::ReadAllRowWiseTuple(reader, records); + return; + } + } + while (true) { auto& record = records->emplace_back(); diff --git a/src/Lightweight/SqlConnection.hpp b/src/Lightweight/SqlConnection.hpp index 76cf8b448..30af026a0 100644 --- a/src/Lightweight/SqlConnection.hpp +++ b/src/Lightweight/SqlConnection.hpp @@ -150,6 +150,40 @@ class SqlConnection final /// @return `true` if the driver honours parameter arrays. [[nodiscard]] bool SupportsNativeRowBatch() const noexcept; + /// @brief Whether this connection's ODBC driver supports native row-array fetching + /// (`SQL_ATTR_ROW_ARRAY_SIZE` > 1 with `SQLFetchScroll`) for block result retrieval. + /// + /// The fast retrieval path in @ref SqlStatement::FetchRecordsInto consults this to decide whether + /// it may bind the result columns row-wise over a record block and materialize whole row blocks per + /// `SQLFetchScroll` round-trip, or must fall back to the per-row `SQLFetch` path. Like + /// @ref SupportsNativeRowBatch this is a driver/backend capability — not a SQL-dialect concern — so + /// it lives on the connection. Kept distinct from the parameter-array flag so a backend that honours + /// one but not the other can be carved out independently. + /// + /// @return `true` if the driver honours row-array fetching. + [[nodiscard]] bool SupportsNativeRowArrayFetch() const noexcept; + + /// @brief Server-type overload of @ref SupportsNativeRowArrayFetch, for callers that hold only the + /// server type (e.g. the DataMapper result reader) and not the connection. Keeps the single source of + /// truth for this capability on the connection rather than scattering a `switch (serverType)` into + /// business logic. + /// @param serverType The backend server type to test. + /// @return `true` if that backend honours row-array fetching. + [[nodiscard]] static bool SupportsNativeRowArrayFetch(SqlServerType serverType) noexcept; + + /// @brief Whether @p serverType's driver round-trips narrow (@c SQL_C_CHAR) character data + /// byte-exact, so a fixed-capacity char string may be array-bound narrow on the row-wise fetch path. + /// + /// PostgreSQL's psqlODBC transcodes @c SQL_C_CHAR through the client codepage (cp1252 on Windows), + /// mangling non-ASCII bytes — its single-row binder therefore reads narrow strings via @c SQL_C_WCHAR. + /// That wide round-trip needs an external per-cell buffer + conversion, which cannot be expressed as an + /// in-place row-wise array bind, so a record carrying a fixed-capacity string falls back to the per-row + /// path on PostgreSQL. MS SQL Server and SQLite read @c SQL_C_CHAR verbatim and stay on the fast path. + /// + /// @param serverType The backend server type to test. + /// @return `true` if narrow character data round-trips byte-exact on that backend. + [[nodiscard]] static bool RoundTripsNarrowTextByteExact(SqlServerType serverType) noexcept; + /// Creates a new query builder for the given table, compatible with the current connection. /// /// @param table The table to query. @@ -300,4 +334,42 @@ inline bool SqlConnection::SupportsNativeRowBatch() const noexcept return false; } +inline bool SqlConnection::SupportsNativeRowArrayFetch(SqlServerType serverType) noexcept +{ + // Native ODBC row-array fetching (SQL_ATTR_ROW_ARRAY_SIZE > 1 + SQLFetchScroll) is a per-driver + // capability. Every backend Lightweight supports and tests against honours it; an unverified backend + // takes the always-correct per-row SQLFetch path rather than risk a driver that mis-handles the array. + switch (serverType) + { + case SqlServerType::MICROSOFT_SQL: + case SqlServerType::POSTGRESQL: + case SqlServerType::SQLITE: + return true; + case SqlServerType::MYSQL: + case SqlServerType::UNKNOWN: + return false; + } + return false; +} + +inline bool SqlConnection::SupportsNativeRowArrayFetch() const noexcept +{ + return SupportsNativeRowArrayFetch(ServerType()); +} + +inline bool SqlConnection::RoundTripsNarrowTextByteExact(SqlServerType serverType) noexcept +{ + switch (serverType) + { + case SqlServerType::MICROSOFT_SQL: + case SqlServerType::SQLITE: + return true; + case SqlServerType::POSTGRESQL: // psqlODBC transcodes SQL_C_CHAR through the client codepage + case SqlServerType::MYSQL: + case SqlServerType::UNKNOWN: + return false; + } + return false; +} + } // namespace Lightweight diff --git a/src/Lightweight/SqlStatement.hpp b/src/Lightweight/SqlStatement.hpp index afebb20b5..3d992d74e 100644 --- a/src/Lightweight/SqlStatement.hpp +++ b/src/Lightweight/SqlStatement.hpp @@ -10,6 +10,7 @@ #include "DataBinder/Core.hpp" #include "DataBinder/SqlDate.hpp" #include "DataBinder/SqlDateTime.hpp" +#include "DataBinder/SqlFixedString.hpp" #include "DataBinder/SqlGuid.hpp" #include "SqlConnection.hpp" #include "SqlQuery.hpp" @@ -18,6 +19,8 @@ #include "TracyProfiler.hpp" #include "Utils.hpp" +#include +#include #include #include #include @@ -309,6 +312,48 @@ class [[nodiscard]] SqlStatement final: public SqlDataBinderCallback template [[nodiscard]] SqlResultCursor ExecuteBatchSoftRowMajor(Rows const& rows, ColumnAccessors const&... accessors); + /// @brief Native row-wise array fetch: materializes the already-executed result set into @p out by + /// binding every result column row-wise over a contiguous block of @p out's records and pulling whole + /// blocks per @c SQLFetchScroll round-trip. The read-side mirror of @ref ExecuteBatchNativeRowWise. + /// + /// Each @p accessors invocable maps a record to one bound column's mutable value reference (the same + /// declaration-order column set the per-row path binds), so the driver writes results in place — no + /// per-cell @c SQLGetData and no intermediate copy. @p out is grown a block at a time and trimmed to + /// the exact row count on the final partial block. + /// + /// @pre Every accessor's value type satisfies @ref SqlRowWiseFetchableColumn and + /// @c sizeof(Record) % alignof(SQLLEN) == 0 (so the row-strided indicator slots stay aligned). + /// The caller (DataMapper) guarantees both before selecting this path. + /// @param out Destination vector; results are appended to its current contents. + /// @param arrayDepth Requested maximum rows per @c SQLFetchScroll (clamped to a memory budget). + /// @param accessors One invocable per result column; @c accessor(record) yields its mutable value. + template + void FetchAllRowWise(std::vector& out, std::size_t arrayDepth, ColumnAccessors const&... accessors); + + /// @brief Row-wise array-binds one output column over a record block; returns the row-strided + /// indicator buffer to feed @ref FinalizeRowWiseOutputColumn. For optional columns every row's + /// optional is pre-engaged so the contained storage is valid to bind into. + template + [[nodiscard]] SQLLEN* BindRowWiseOutputColumn(SQLUSMALLINT column, + void* base0, + std::size_t rowStride, + std::size_t depth); + + /// @brief Issues the row-wise @c SQLBindCol for one non-optional value type @p Value at @p base0 (the + /// value slot in record 0; the driver strides it by the active @c SQL_ATTR_ROW_BIND_TYPE). Fixed- + /// capacity char strings bind their inline buffer as @c SQL_C_CHAR (length fixed up per row + /// afterwards); all other types bind in place via their @c SqlDataBinder::OutputColumn. + template + void BindRowWiseValue(SQLUSMALLINT column, void* base0, SQLLEN* indicators); + + /// @brief Post-fetch fixup for one row-wise output column: resets each NULL row's @c std::optional to + /// @c std::nullopt (no-op for non-optional columns, whose value is materialized in place). + template + static void FinalizeRowWiseOutputColumn(void* base0, + std::size_t rowStride, + std::size_t rowCount, + SQLLEN const* indicators) noexcept; + template [[nodiscard]] std::optional GetNullableColumn(SQLUSMALLINT column) const; @@ -444,6 +489,20 @@ class [[nodiscard]] SqlResultCursor m_stmt->BindOutputColumnsToRecord(records...); } + /// @brief Fast bulk retrieval: materializes this result set into @p out via native ODBC row-wise + /// array fetch. Forwards to @ref SqlStatement::FetchAllRowWise; see its contract (eligibility and + /// alignment preconditions are the caller's responsibility). + /// @param out Destination vector; results are appended. + /// @param arrayDepth Requested maximum rows per @c SQLFetchScroll round-trip. + /// @param accessors One invocable per result column; @c accessor(record) yields its mutable value. + template + LIGHTWEIGHT_FORCE_INLINE void FetchAllRowWise(std::vector& out, + std::size_t arrayDepth, + ColumnAccessors const&... accessors) + { + m_stmt->FetchAllRowWise(out, arrayDepth, accessors...); + } + /// Retrieves the value of the column at the given index for the currently selected row. /// /// Returns true if the value is not NULL, false otherwise. @@ -1025,6 +1084,19 @@ concept SqlOptionalRowBindable = template concept SqlRowBindableColumn = SqlNativeRowBindableValue || SqlOptionalRowBindable; +/// @brief A column usable on the native row-wise array-FETCH fast path. Intentionally identical to the +/// write-side @ref SqlRowBindableColumn — the set of types we can bind row-wise into a record block on +/// fetch matches the set we can bind row-wise as a parameter array on execute: fixed-width primitives, +/// date/time/datetime, numeric, char-based fixed-capacity strings, and non-numeric optionals of those. +/// +/// Char fixed strings are materialized by a dedicated SQL_C_CHAR bind plus a per-row length/trim fixup +/// (see @ref BindRowWiseOutputColumn / @ref FinalizeRowWiseOutputColumn); on PostgreSQL, whose driver +/// transcodes SQL_C_CHAR through the client codepage, records carrying one fall back to the per-row +/// (wide) path instead — see @ref SqlConnection::RoundTripsNarrowTextByteExact. Growable strings/binary, +/// GUID and variant are not row-bindable and make the whole record fall back to the per-row fetch path. +template +concept SqlRowWiseFetchableColumn = SqlRowBindableColumn; + /// @brief Whether @p V's binder provides a row-wise batch entry point (@c BatchRowWiseInputParameter). /// /// Such types (e.g. @c std::optional of a fixed type, or inline fixed-capacity strings) need a @@ -1256,6 +1328,188 @@ SqlResultCursor SqlStatement::ExecuteBatchSoftRowMajor(Rows const& rows, ColumnA return SqlResultCursor { *this }; } +template +void SqlStatement::BindRowWiseValue(SQLUSMALLINT column, void* base0, SQLLEN* indicators) +{ + if constexpr (IsSqlFixedString) + { + // Char fixed-capacity strings are stored inline, so each row's character buffer is reached at + // Data(row0) + i*rowStride. Bind it as SQL_C_CHAR with the Capacity(+NUL) buffer length (matching + // the non-PostgreSQL single-row OutputColumn); FinalizeRowWiseOutputColumn sets each row's length + // from its indicator and applies the trailing-whitespace/NUL trim. PostgreSQL never reaches here: + // such records take the per-row (wide) path (see SqlConnection::RoundTripsNarrowTextByteExact). + RequireSuccess(SQLBindCol(m_hStmt, + column, + SQL_C_CHAR, + (SQLPOINTER) SqlBasicStringOperations::Data(static_cast(base0)), + static_cast(Value::Capacity) + 1, + indicators)); + } + else + { + // Fixed-width value (primitive, date/time/datetime, numeric): a plain, callback-free SQLBindCol + // straight into the record field; the driver strides by rowStride. + RequireSuccess(SqlDataBinder::OutputColumn(m_hStmt, column, static_cast(base0), indicators, *this)); + } +} + +template +SQLLEN* SqlStatement::BindRowWiseOutputColumn(SQLUSMALLINT column, void* base0, std::size_t rowStride, std::size_t depth) +{ + // Row-wise binding strides the indicator pointer by SQL_ATTR_ROW_BIND_TYPE (== rowStride), the same + // as the value pointer; there is no separate indicator stride. So the indicator array over-allocates + // to rowStride per row (only sizeof(SQLLEN) of each slot is used) — intrinsic to ODBC row-wise + // binding, identical to the write side (see SqlDataBinderCallback::ProvideBatchStagingBuffer). + auto* const indicatorBytes = ProvideBatchStagingBuffer(((depth - 1) * rowStride) + sizeof(SQLLEN)); + auto* const indicators = reinterpret_cast(indicatorBytes); + + if constexpr (SqlIsStdOptional) + { + using Inner = typename ValueType::value_type; + auto* const optBytes = static_cast(base0); + // Pre-engage every row's optional so its contained storage is valid to bind into; rows that come + // back NULL are reset to std::nullopt in FinalizeRowWiseOutputColumn. + for (auto const i: std::views::iota(std::size_t { 0 }, depth)) + reinterpret_cast(optBytes + (i * rowStride))->emplace(); + // The contained value of row 0 (constant offset within every optional); the driver strides it by + // rowStride to reach each row's contained storage in place. + auto* const contained0 = reinterpret_cast(optBytes + detail::OptionalValueOffset()); + BindRowWiseValue(column, contained0, indicators); + } + else + { + BindRowWiseValue(column, base0, indicators); + } + return indicators; +} + +template +void SqlStatement::FinalizeRowWiseOutputColumn(void* base0, + std::size_t rowStride, + std::size_t rowCount, + SQLLEN const* indicators) noexcept +{ + auto const indicatorAt = [&](std::size_t i) noexcept { + return *reinterpret_cast(reinterpret_cast(indicators) + (i * rowStride)); + }; + + if constexpr (SqlIsStdOptional) + { + using Inner = typename ValueType::value_type; + auto* const optBytes = static_cast(base0); + for (auto const i: std::views::iota(std::size_t { 0 }, rowCount)) + { + auto* const optional = reinterpret_cast(optBytes + (i * rowStride)); + if (indicatorAt(i) == SQL_NULL_DATA) + optional->reset(); + else if constexpr (IsSqlFixedString) + // Engaged char fixed string: set its length and trim, matching the single-row binder. + SqlBasicStringOperations::PostProcessOutputColumn(std::addressof(**optional), indicatorAt(i)); + // Engaged fixed-width inner: already materialized in place, nothing more to do. + } + } + else if constexpr (IsSqlFixedString) + { + auto* const base = static_cast(base0); + for (auto const i: std::views::iota(std::size_t { 0 }, rowCount)) + SqlBasicStringOperations::PostProcessOutputColumn( + reinterpret_cast(base + (i * rowStride)), indicatorAt(i)); + } + // Plain fixed-width non-optional columns: the value is materialized in place; a NULL leaves the + // default-constructed value untouched, matching the single-row bound-output path. +} + +template +void SqlStatement::FetchAllRowWise(std::vector& out, std::size_t arrayDepth, ColumnAccessors const&... accessors) +{ + ZoneScopedN("SqlStatement::FetchAllRowWise"); + ZoneTextObject(m_preparedQuery); + + static_assert(sizeof...(ColumnAccessors) >= 1, "FetchAllRowWise requires at least one column accessor"); + constexpr std::size_t columnCount = sizeof...(ColumnAccessors); + + // Adapt the depth to the per-cursor memory budget. The row-strided indicator staging over-allocates + // to sizeof(Record) per row per column, so the per-row footprint is sizeof(Record) * (1 + columns) + // (data block + one indicator buffer per column). Clamp like RowArrayCursor so wide rows bind fewer + // rows per round-trip instead of exhausting memory. + { + auto const perRow = sizeof(Record) * (1 + columnCount); + auto const budgetDepth = RowArrayCursor::MemoryBudgetBytes / std::max(perRow, 1); + auto const minDepth = std::min(RowArrayCursor::MinArrayDepth, arrayDepth); // never raise above the request + arrayDepth = std::clamp(budgetDepth, minDepth, arrayDepth); + } + + std::vector rowStatus(arrayDepth); + SQLULEN rowsFetched = 0; + + // Restore single-row, column-bound fetch state and release staging buffers on EVERY exit — success or + // exception — so a throwing bind/fetch can never leave the handle in a stale row-array state for a + // later reuse. Mirrors ExecuteBatchNativeRowWise's restoreParameterBinding guard. + auto const restoreFetchState = detail::Finally([this] { + SQLFreeStmt(m_hStmt, SQL_UNBIND); + // clang-format off + // NOLINTNEXTLINE(performance-no-int-to-ptr) + SQLSetStmtAttr(m_hStmt, SQL_ATTR_ROW_ARRAY_SIZE, (SQLPOINTER) 1, 0); + SQLSetStmtAttr(m_hStmt, SQL_ATTR_ROW_BIND_TYPE, SQL_BIND_BY_COLUMN, 0); + SQLSetStmtAttr(m_hStmt, SQL_ATTR_ROW_STATUS_PTR, nullptr, 0); + SQLSetStmtAttr(m_hStmt, SQL_ATTR_ROWS_FETCHED_PTR, nullptr, 0); + // clang-format on + ClearBatchIndicators(); + }); + + // clang-format off + // NOLINTNEXTLINE(performance-no-int-to-ptr) + RequireSuccess(SQLSetStmtAttr(m_hStmt, SQL_ATTR_ROW_BIND_TYPE, (SQLPOINTER) sizeof(Record), 0)); + // NOLINTNEXTLINE(performance-no-int-to-ptr) + RequireSuccess(SQLSetStmtAttr(m_hStmt, SQL_ATTR_ROW_ARRAY_SIZE, (SQLPOINTER) arrayDepth, 0)); + RequireSuccess(SQLSetStmtAttr(m_hStmt, SQL_ATTR_ROW_STATUS_PTR, rowStatus.data(), 0)); + RequireSuccess(SQLSetStmtAttr(m_hStmt, SQL_ATTR_ROWS_FETCHED_PTR, &rowsFetched, 0)); + // clang-format on + + for (;;) + { + std::size_t const base = out.size(); + out.resize(base + arrayDepth); + Record* const row0 = out.data() + base; + + // Rebind each column into this block's records (the value pointer follows out's storage across a + // reallocation) and refresh the per-column row-strided indicator buffers. + ClearBatchIndicators(); + std::array indicators {}; + SQLUSMALLINT column = 0; + std::size_t bindIndex = 0; + ((indicators[bindIndex++] = BindRowWiseOutputColumn>( + ++column, std::addressof(accessors(*row0)), sizeof(Record), arrayDepth)), + ...); + + rowsFetched = 0; + auto const fetchResult = SQLFetchScroll(m_hStmt, SQL_FETCH_NEXT, 0); + if (fetchResult == SQL_NO_DATA) + { + out.resize(base); + break; + } + // SQL_SUCCESS_WITH_INFO is acceptable: rowsFetched stays valid. The fixed-width eligibility gate + // keeps the bound columns from truncating, so it should not occur for these columns in practice. + if (!SQL_SUCCEEDED(fetchResult)) + RequireSuccess(fetchResult); + + auto const fetched = static_cast(rowsFetched); + SqlLogger::GetLogger().OnFetchRow(); // one block-fetch round-trip (vs. one per row on the slow path) + + std::size_t finalizeIndex = 0; + (FinalizeRowWiseOutputColumn>( + std::addressof(accessors(*row0)), sizeof(Record), fetched, indicators[finalizeIndex++]), + ...); + + out.resize(base + fetched); + if (fetched < arrayDepth) + break; + } + + SqlLogger::GetLogger().OnFetchEnd(); +} + template inline bool SqlStatement::GetColumn(SQLUSMALLINT column, T* result) const { diff --git a/src/tests/CMakeLists.txt b/src/tests/CMakeLists.txt index 2e9b2c11e..559a9cf38 100644 --- a/src/tests/CMakeLists.txt +++ b/src/tests/CMakeLists.txt @@ -49,6 +49,7 @@ set(SOURCE_FILES DataMapper/MiscTests.cpp DataMapper/NamingTests.cpp DataMapper/ReadTests.cpp + DataMapper/RowWiseFetchTests.cpp DataMapper/RelationTests.cpp DataMapper/BelongsToStateTests.cpp DataMapper/StateTests.cpp diff --git a/src/tests/DataMapper/RowWiseFetchTests.cpp b/src/tests/DataMapper/RowWiseFetchTests.cpp new file mode 100644 index 000000000..788e3a6b2 --- /dev/null +++ b/src/tests/DataMapper/RowWiseFetchTests.cpp @@ -0,0 +1,371 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include "../Utils.hpp" + +#include + +#include + +#include +#include + +#include +#include +#include +#include +#include + +using namespace Lightweight; + +// NOLINTBEGIN(bugprone-unchecked-optional-access) + +// Record types must have linkage (not in an anonymous namespace) for reflection-cpp. + +// All fixed-width columns: Query<>().All()/Range() take the native row-wise array-fetch fast path. +struct RowFixedRecord +{ + Field id; + Field big; + Field ratio; + Field mid; + Field tiny; + + bool operator==(RowFixedRecord const&) const = default; +}; + +// Nullable fixed-width columns: exercises the pre-engage + reset-on-NULL row-wise output path. +struct RowNullableRecord +{ + Field id; + Field> maybeInt; + Field> maybeRatio; +}; + +// Temporal columns incl. a nullable timestamp: native row-wise fetch via SqlDate/SqlDateTime binders. +struct RowTemporalRecord +{ + Field id; + Field date; + Field> when; +}; + +// Inline fixed-capacity char strings (SqlAnsiString is SqlFixedString), incl. a nullable one: +// row-wise fetchable via the SQL_C_CHAR inline bind + per-row length/trim fixup (per-row fallback on PG). +struct RowFixedStringRecord +{ + Field id; + Field> name; + Field>> code; + Field number; +}; + +// Contains a std::string (growable): NOT row-wise fetchable, must fall back to the per-row path. +struct RowStringRecord +{ + Field id; + Field name; + Field number; +}; + +// The eligibility predicate must match the design exactly: fixed-width (incl. nullable fixed and inline +// fixed-capacity strings) records are row-wise fetchable; a record carrying a growable string is not. +static_assert(detail::CanRowWiseFetchRecord()); +static_assert(detail::CanRowWiseFetchRecord()); +static_assert(detail::CanRowWiseFetchRecord()); +static_assert(detail::CanRowWiseFetchRecord()); +static_assert(!detail::CanRowWiseFetchRecord()); + +// The fixed-string record is flagged as narrow-text-bearing (so PostgreSQL falls back), the others not. +static_assert(detail::RecordHasNarrowFixedStringColumn()); +static_assert(!detail::RecordHasNarrowFixedStringColumn()); + +// Counts block-fetch round-trips: the row-wise fast path emits one OnFetchRow per SQLFetchScroll block, +// the per-row fallback emits one per row — so the count proves which path actually ran. +class FetchBlockCountingLogger: public SqlLogger::Null +{ + public: + int fetchEvents = 0; + + void OnFetchRow() override + { + ++fetchEvents; + } +}; + +template +int CountFetchEvents(Fn&& fn) +{ + FetchBlockCountingLogger logger; + auto& previousLogger = SqlLogger::GetLogger(); + SqlLogger::SetLogger(logger); + std::forward(fn)(); + SqlLogger::SetLogger(previousLogger); + return logger.fetchEvents; +} + +TEST_CASE_METHOD(SqlTestFixture, + "RowWiseFetch: All() materializes fixed-width records in row blocks", + "[DataMapper][rowwisefetch]") +{ + auto dm = DataMapper {}; + dm.CreateTable(); + + auto seed = std::vector {}; + for (auto const i: std::views::iota(1, 51)) + seed.push_back({ .id = i, .big = i * 1000, .ratio = i * 1.5, .mid = i * 10, .tiny = static_cast(i) }); + dm.CreateAll(seed); + + std::vector records; + auto const fetchEvents = CountFetchEvents([&] { records = dm.Query().All(); }); + + // 50 rows fit in a single SQLFetchScroll block (default depth 1024): exactly one block-fetch, not 50. + CHECK(fetchEvents == 1); + + REQUIRE(records.size() == 50); + std::map byId; + for (auto const& r: records) + byId.emplace(r.id.Value(), r); + for (auto const& expected: seed) + { + auto const it = byId.find(expected.id.Value()); + REQUIRE(it != byId.end()); + CHECK(it->second.big.Value() == expected.big.Value()); + CHECK_THAT(it->second.ratio.Value(), Catch::Matchers::WithinAbs(expected.ratio.Value(), 0.000'001)); + CHECK(it->second.mid.Value() == expected.mid.Value()); + CHECK(it->second.tiny.Value() == expected.tiny.Value()); + } +} + +TEST_CASE_METHOD(SqlTestFixture, "RowWiseFetch: NULLs in nullable columns round-trip", "[DataMapper][rowwisefetch]") +{ + auto dm = DataMapper {}; + dm.CreateTable(); + + auto seed = std::vector {}; + seed.push_back({ .id = 1, .maybeInt = 42, .maybeRatio = 3.5 }); + seed.push_back({ .id = 2, .maybeInt = std::nullopt, .maybeRatio = 9.0 }); // NULL int + seed.push_back({ .id = 3, .maybeInt = 7, .maybeRatio = std::nullopt }); // NULL ratio + seed.push_back({ .id = 4, .maybeInt = std::nullopt, .maybeRatio = std::nullopt }); // all NULL + dm.CreateAll(seed); + + auto const records = dm.Query().All(); + REQUIRE(records.size() == 4); + + std::map byId; + for (auto const& r: records) + byId.emplace(r.id.Value(), &r); + + CHECK(byId[1]->maybeInt.Value() == std::optional { 42 }); + CHECK(byId[1]->maybeRatio.Value() == std::optional { 3.5 }); + CHECK_FALSE(byId[2]->maybeInt.Value().has_value()); + CHECK(byId[2]->maybeRatio.Value() == std::optional { 9.0 }); + CHECK(byId[3]->maybeInt.Value() == std::optional { 7 }); + CHECK_FALSE(byId[3]->maybeRatio.Value().has_value()); + CHECK_FALSE(byId[4]->maybeInt.Value().has_value()); + CHECK_FALSE(byId[4]->maybeRatio.Value().has_value()); +} + +TEST_CASE_METHOD(SqlTestFixture, "RowWiseFetch: temporal columns and nullable timestamp", "[DataMapper][rowwisefetch]") +{ + auto dm = DataMapper {}; + dm.CreateTable(); + + auto seed = std::vector {}; + seed.push_back({ .id = 1, + .date = SqlDate { std::chrono::year { 2026 }, std::chrono::month { 6 }, std::chrono::day { 22 } }, + .when = SqlDateTime { std::chrono::year { 2026 }, + std::chrono::month { 6 }, + std::chrono::day { 22 }, + std::chrono::hours { 8 }, + std::chrono::minutes { 30 }, + std::chrono::seconds { 45 }, + std::chrono::nanoseconds { 0 } } }); + seed.push_back({ .id = 2, + .date = SqlDate { std::chrono::year { 1999 }, std::chrono::month { 12 }, std::chrono::day { 31 } }, + .when = std::nullopt }); + dm.CreateAll(seed); + + auto const records = dm.Query().All(); + REQUIRE(records.size() == 2); + + std::map byId; + for (auto const& r: records) + byId.emplace(r.id.Value(), &r); + + CHECK(byId[1]->date.Value() == seed[0].date.Value()); + REQUIRE(byId[1]->when.Value().has_value()); + CHECK(byId[1]->when.Value().value() == seed[0].when.Value().value()); + CHECK(byId[2]->date.Value() == seed[1].date.Value()); + CHECK_FALSE(byId[2]->when.Value().has_value()); +} + +TEST_CASE_METHOD(SqlTestFixture, + "RowWiseFetch: block boundary across multiple SQLFetchScroll calls", + "[DataMapper][rowwisefetch]") +{ + auto dm = DataMapper {}; + dm.CreateTable(); + + // Two full blocks plus a partial last block proves the grow-and-rebind loop and the trim of the + // final partial block (rowcount % depth != 0). + constexpr auto depth = detail::kDefaultRowArrayFetchDepth; + auto const total = static_cast(depth * 2 + 7); + + auto seed = std::vector {}; + for (auto const i: std::views::iota(1, total + 1)) + seed.push_back({ .id = i, .big = i, .ratio = i * 0.5, .mid = i, .tiny = static_cast(i % 100) }); + dm.CreateAll(seed); + + std::vector records; + auto const fetchEvents = CountFetchEvents([&] { records = dm.Query().All(); }); + + CHECK(fetchEvents == 3); // 1024 + 1024 + 7 + REQUIRE(records.size() == static_cast(total)); + + // Spot-check the first, a mid-block-boundary, and the last row. + std::map byId; + for (auto const& r: records) + byId.emplace(r.id.Value(), &r); + CHECK(byId[1]->big.Value() == 1); + CHECK(byId[static_cast(depth)]->big.Value() == static_cast(depth)); + CHECK(byId[static_cast(depth) + 1]->big.Value() == static_cast(depth) + 1); + CHECK(byId[total]->big.Value() == total); +} + +TEST_CASE_METHOD(SqlTestFixture, "RowWiseFetch: empty result set", "[DataMapper][rowwisefetch]") +{ + auto dm = DataMapper {}; + dm.CreateTable(); + + std::vector records; + auto const fetchEvents = CountFetchEvents([&] { records = dm.Query().All(); }); + + CHECK(fetchEvents == 0); // SQLFetchScroll immediately returns SQL_NO_DATA + CHECK(records.empty()); +} + +TEST_CASE_METHOD(SqlTestFixture, "RowWiseFetch: Where/Range stay correct on the fast path", "[DataMapper][rowwisefetch]") +{ + auto dm = DataMapper {}; + dm.CreateTable(); + + auto seed = std::vector {}; + for (auto const i: std::views::iota(1, 21)) + seed.push_back({ .id = i, .big = i, .ratio = i * 1.0, .mid = i, .tiny = static_cast(i) }); + dm.CreateAll(seed); + + SECTION("Where + All") + { + auto const records = dm.Query().Where(FieldNameOf, ">=", 15).All(); + CHECK(records.size() == 6); // ids 15..20 + for (auto const& r: records) + CHECK(r.mid.Value() >= 15); + } + + SECTION("Range") + { + auto const records = dm.Query().OrderBy(FieldNameOf).Range(5, 4); + REQUIRE(records.size() == 4); + CHECK(records.front().id.Value() == 6); // offset 5 -> 6th row (1-based ids) + } +} + +TEST_CASE_METHOD(SqlTestFixture, + "RowWiseFetch: inline fixed-capacity strings (with NULL/empty/full)", + "[DataMapper][rowwisefetch]") +{ + auto dm = DataMapper {}; + dm.CreateTable(); + + auto seed = std::vector {}; + seed.push_back({ .id = 1, .name = SqlAnsiString<32> { "Alice" }, .code = SqlAnsiString<16> { "AAA" }, .number = 10 }); + seed.push_back({ .id = 2, .name = SqlAnsiString<32> {}, .code = std::nullopt, .number = 20 }); // empty name, NULL code + seed.push_back({ .id = 3, + .name = SqlAnsiString<32> { "Charlie Brown the Third Esq." }, + .code = SqlAnsiString<16> { "0123456789012345" }, // 16 chars: fills capacity exactly + .number = 30 }); + dm.CreateAll(seed); + + std::vector records; + auto const fetchEvents = CountFetchEvents([&] { records = dm.Query().All(); }); + + // Where narrow text round-trips byte-exact (MSSQL/SQLite) the fast path runs: one block-fetch. + // PostgreSQL keeps the per-row (wide) path for fixed strings: one fetch per row. + if (SqlConnection::RoundTripsNarrowTextByteExact(dm.Connection().ServerType())) + CHECK(fetchEvents == 1); + else + CHECK(fetchEvents == 3); + + REQUIRE(records.size() == 3); + std::map byId; + for (auto const& r: records) + byId.emplace(r.id.Value(), &r); + + CHECK(byId[1]->name.Value() == "Alice"); + REQUIRE(byId[1]->code.Value().has_value()); + CHECK(byId[1]->code.Value().value() == "AAA"); + CHECK(byId[1]->number.Value() == 10); + + CHECK(byId[2]->name.Value().empty()); + CHECK_FALSE(byId[2]->code.Value().has_value()); + CHECK(byId[2]->number.Value() == 20); + + CHECK(byId[3]->name.Value() == "Charlie Brown the Third Esq."); + REQUIRE(byId[3]->code.Value().has_value()); + CHECK(byId[3]->code.Value().value() == "0123456789012345"); +} + +TEST_CASE_METHOD(SqlTestFixture, + "RowWiseFetch: record with std::string falls back, still correct", + "[DataMapper][rowwisefetch]") +{ + auto dm = DataMapper {}; + dm.CreateTable(); + + auto seed = std::vector {}; + seed.push_back({ .id = 1, .name = std::string { "alpha" }, .number = 10 }); + seed.push_back({ .id = 2, .name = std::string { "" }, .number = 20 }); + seed.push_back({ .id = 3, .name = std::string { "a longer value than before" }, .number = 30 }); + dm.CreateAll(seed); + + std::vector records; + auto const fetchEvents = CountFetchEvents([&] { records = dm.Query().All(); }); + + // Not row-wise fetchable: the per-row path runs, so one OnFetchRow per row (3), not one block. + CHECK(fetchEvents == 3); + + REQUIRE(records.size() == 3); + std::map byId; + for (auto const& r: records) + byId.emplace(r.id.Value(), &r); + CHECK(byId[1]->name.Value() == "alpha"); + CHECK(byId[2]->name.Value().empty()); + CHECK(byId[3]->name.Value() == "a longer value than before"); + CHECK(byId[3]->number.Value() == 30); +} + +TEST_CASE_METHOD(SqlTestFixture, "RowWiseFetch: statement is reusable after a fast fetch", "[DataMapper][rowwisefetch]") +{ + auto dm = DataMapper {}; + dm.CreateTable(); + + auto seed = std::vector {}; + for (auto const i: std::views::iota(1, 11)) + seed.push_back({ .id = i, .big = i, .ratio = i * 1.0, .mid = i, .tiny = static_cast(i) }); + dm.CreateAll(seed); + + auto const first = dm.Query().All(); + CHECK(first.size() == 10); + + // After the row-array attributes were restored, ordinary single-row queries must still work. + CHECK(dm.Query().Count() == 10); + auto const single = dm.QuerySingle(5); + REQUIRE(single.has_value()); + CHECK(single->big.Value() == 5); + + // A second fast fetch on a fresh query also works. + auto const second = dm.Query().All(); + CHECK(second.size() == 10); +} + +// NOLINTEND(bugprone-unchecked-optional-access) From 8711eed4bf3d1dc059730fbf61b39508230ad3c7 Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Mon, 22 Jun 2026 11:13:19 +0200 Subject: [PATCH 2/6] [tests] Add opt-in benchmark proving row-wise fetch beats per-row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A hidden ([.rowwisefetchbench]) benchmark times the shipped row-wise block fetch (Query<>().All()) against a faithful reproduction of the per-row fallback over a large dataset, materializing the same vector. Tunable via the ROWFETCH_BENCH_ROWS env var (default 500'000). Measured (release, median of 5 reps): - SQLite in-process : ~1.2x (per-SQLFetch overhead only; no socket) - PostgreSQL (TCP) : ~1.5x - SQL Server (TCP) : ~2.0x The win grows with per-round-trip transport cost: in-process gains little, a localhost socket already 1.5-2x. For 200k rows the row-wise path issues ~196 SQLFetchScroll calls vs 200k SQLFetch calls (~1000x fewer round-trips), so on a high-latency link — where wall-clock is dominated by RTT * round-trips — the speedup approaches the array depth. Signed-off-by: Christian Parpart --- src/tests/DataMapper/RowWiseFetchTests.cpp | 129 +++++++++++++++++++++ 1 file changed, 129 insertions(+) diff --git a/src/tests/DataMapper/RowWiseFetchTests.cpp b/src/tests/DataMapper/RowWiseFetchTests.cpp index 788e3a6b2..7ed77f18f 100644 --- a/src/tests/DataMapper/RowWiseFetchTests.cpp +++ b/src/tests/DataMapper/RowWiseFetchTests.cpp @@ -9,10 +9,15 @@ #include #include +#include +#include +#include +#include #include #include #include #include +#include #include using namespace Lightweight; @@ -368,4 +373,128 @@ TEST_CASE_METHOD(SqlTestFixture, "RowWiseFetch: statement is reusable after a fa CHECK(second.size() == 10); } +// Hidden ([.]) opt-in benchmark: proves the row-wise block fetch beats the per-row path. Run with e.g. +// LightweightTest --test-env=sqlite3 "[rowwisefetchbench]" +// and tune the dataset size via the ROWFETCH_BENCH_ROWS environment variable (default 500'000). +// +// It compares the shipped fast path (Query<>().All(), one SQLFetchScroll per block) against a faithful +// reproduction of the per-row fallback (emplace + BindOutputColumnsToRecord + FetchRow per row — exactly +// what detail::ReadSingleResult does). Both materialize the same std::vector, so the only +// difference is the fetch mechanism. Locally (no network) this isolates the per-SQLFetch driver overhead, +// which is the lower bound of the win: on a high-latency link each eliminated round-trip also saves a full +// network RTT, so the real-world speedup is far larger than the local number. +TEST_CASE_METHOD(SqlTestFixture, "RowWiseFetch.benchmark: block fetch vs per-row", "[.][rowwisefetchbench]") +{ + using std::chrono::microseconds; + using std::chrono::steady_clock; + + auto const* const rowsEnv = std::getenv("ROWFETCH_BENCH_ROWS"); + std::size_t const rowCount = rowsEnv != nullptr ? std::stoul(rowsEnv) : 500'000; + constexpr int reps = 5; + + auto const nullLogger = ScopedSqlNullLogger {}; + auto dm = DataMapper {}; + dm.CreateTable(); + + // Seed the table (insertion time is not measured). + { + constexpr std::size_t chunk = 10'000; + for (std::size_t inserted = 0; inserted < rowCount;) + { + auto const n = std::min(chunk, rowCount - inserted); + std::vector batch; + batch.reserve(n); + for (auto const i: std::views::iota(std::size_t { 0 }, n)) + { + auto const id = static_cast(inserted + i + 1); + batch.push_back({ .id = id, + .big = id * 7, + .ratio = static_cast(id), + .mid = static_cast(id % 100'000), + .tiny = static_cast(id % 100) }); + } + dm.CreateAll(batch); + inserted += n; + } + } + REQUIRE(dm.Query().Count() == rowCount); + + auto const selectSql = std::format(R"(SELECT * FROM "{}")", RecordTableName); + + // Shipped fast path: native row-wise array fetch (one SQLFetchScroll per block). + auto const fast = [&]() -> std::pair { + auto const records = dm.Query().All(); + std::int64_t checksum = 0; + for (auto const& r: records) + checksum += r.big.Value(); + return { records.size(), checksum }; + }; + + // Per-row fallback, reproduced via the public cursor API: rebind output columns to each freshly + // emplaced record and FetchRow once per row — one SQLFetch round-trip per row. + auto const slow = [&]() -> std::pair { + auto stmt = SqlStatement { dm.Connection() }; + auto cursor = stmt.ExecuteDirect(selectSql); + std::vector records; + while (true) + { + auto& record = records.emplace_back(); + cursor.BindOutputColumnsToRecord(&record); + if (!cursor.FetchRow()) + { + records.pop_back(); + break; + } + } + std::int64_t checksum = 0; + for (auto const& r: records) + checksum += r.big.Value(); + return { records.size(), checksum }; + }; + + // Warm up once and assert the two paths return identical data before timing. + auto const fastWarm = fast(); + auto const slowWarm = slow(); + REQUIRE(fastWarm.first == rowCount); + REQUIRE(slowWarm.first == rowCount); + CHECK(fastWarm.second == slowWarm.second); + + auto const medianMicros = [&](auto const& fn) { + std::vector samples; + samples.reserve(reps); + for (int i = 0; i < reps; ++i) + { + auto const start = steady_clock::now(); + auto const result = fn(); + auto const elapsed = steady_clock::now() - start; + CHECK(result.first == rowCount); + samples.push_back(std::chrono::duration_cast(elapsed).count()); + } + std::ranges::sort(samples); + return samples[samples.size() / 2]; + }; + + auto const slowMicros = medianMicros(slow); + auto const fastMicros = medianMicros(fast); + + auto const rowsPerSec = [&](std::int64_t micros) { + return static_cast(rowCount) * 1e6 / static_cast(micros); + }; + auto const speedup = static_cast(slowMicros) / static_cast(fastMicros); + + WARN(std::format("\n[RowWiseFetch benchmark] {} rows, median of {} reps\n" + " per-row SQLFetch : {:>9} us ({:>12.0f} rows/s)\n" + " row-wise block fetch : {:>9} us ({:>12.0f} rows/s)\n" + " speedup : {:.2f}x\n", + rowCount, + reps, + slowMicros, + rowsPerSec(slowMicros), + fastMicros, + rowsPerSec(fastMicros), + speedup)); + + CHECK(fastMicros <= slowMicros); // the block fetch must not be slower than the per-row path +} + // NOLINTEND(bugprone-unchecked-optional-access) From b96f9adab96405eda2574dfdc5da48ce17884544 Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Mon, 22 Jun 2026 12:03:51 +0200 Subject: [PATCH 3/6] [DataMapper] Fix clang-tidy and doxygen CI failures - RowWiseFetchTests: parenthesize depth*2+7 (readability-math-missing-parentheses) and use std::cmp_equal for the depth/total spot-checks (modernize-use-integer-sign-comparison). - Doc comments: doxygen cannot resolve @ref to private members or concepts, so the public comments now use @c for SqlRowBindableColumn, FetchAllRowWise, BindRowWiseOutputColumn, FinalizeRowWiseOutputColumn and RoundTripsNarrowTextByteExact; also fix a stale @ref to a renamed method. Signed-off-by: Christian Parpart --- src/Lightweight/SqlConnection.hpp | 2 +- src/Lightweight/SqlStatement.hpp | 14 +++++++------- src/tests/DataMapper/RowWiseFetchTests.cpp | 8 ++++---- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/Lightweight/SqlConnection.hpp b/src/Lightweight/SqlConnection.hpp index 30af026a0..25dca672e 100644 --- a/src/Lightweight/SqlConnection.hpp +++ b/src/Lightweight/SqlConnection.hpp @@ -153,7 +153,7 @@ class SqlConnection final /// @brief Whether this connection's ODBC driver supports native row-array fetching /// (`SQL_ATTR_ROW_ARRAY_SIZE` > 1 with `SQLFetchScroll`) for block result retrieval. /// - /// The fast retrieval path in @ref SqlStatement::FetchRecordsInto consults this to decide whether + /// The fast retrieval path in @c SqlStatement::FetchAllRowWise consults this to decide whether /// it may bind the result columns row-wise over a record block and materialize whole row blocks per /// `SQLFetchScroll` round-trip, or must fall back to the per-row `SQLFetch` path. Like /// @ref SupportsNativeRowBatch this is a driver/backend capability — not a SQL-dialect concern — so diff --git a/src/Lightweight/SqlStatement.hpp b/src/Lightweight/SqlStatement.hpp index 3d992d74e..6e5dcd423 100644 --- a/src/Lightweight/SqlStatement.hpp +++ b/src/Lightweight/SqlStatement.hpp @@ -314,14 +314,14 @@ class [[nodiscard]] SqlStatement final: public SqlDataBinderCallback /// @brief Native row-wise array fetch: materializes the already-executed result set into @p out by /// binding every result column row-wise over a contiguous block of @p out's records and pulling whole - /// blocks per @c SQLFetchScroll round-trip. The read-side mirror of @ref ExecuteBatchNativeRowWise. + /// blocks per @c SQLFetchScroll round-trip. The read-side mirror of @c ExecuteBatchNativeRowWise. /// /// Each @p accessors invocable maps a record to one bound column's mutable value reference (the same /// declaration-order column set the per-row path binds), so the driver writes results in place — no /// per-cell @c SQLGetData and no intermediate copy. @p out is grown a block at a time and trimmed to /// the exact row count on the final partial block. /// - /// @pre Every accessor's value type satisfies @ref SqlRowWiseFetchableColumn and + /// @pre Every accessor's value type satisfies @c SqlRowWiseFetchableColumn and /// @c sizeof(Record) % alignof(SQLLEN) == 0 (so the row-strided indicator slots stay aligned). /// The caller (DataMapper) guarantees both before selecting this path. /// @param out Destination vector; results are appended to its current contents. @@ -331,7 +331,7 @@ class [[nodiscard]] SqlStatement final: public SqlDataBinderCallback void FetchAllRowWise(std::vector& out, std::size_t arrayDepth, ColumnAccessors const&... accessors); /// @brief Row-wise array-binds one output column over a record block; returns the row-strided - /// indicator buffer to feed @ref FinalizeRowWiseOutputColumn. For optional columns every row's + /// indicator buffer to feed @c FinalizeRowWiseOutputColumn. For optional columns every row's /// optional is pre-engaged so the contained storage is valid to bind into. template [[nodiscard]] SQLLEN* BindRowWiseOutputColumn(SQLUSMALLINT column, @@ -490,7 +490,7 @@ class [[nodiscard]] SqlResultCursor } /// @brief Fast bulk retrieval: materializes this result set into @p out via native ODBC row-wise - /// array fetch. Forwards to @ref SqlStatement::FetchAllRowWise; see its contract (eligibility and + /// array fetch. Forwards to @c SqlStatement::FetchAllRowWise; see its contract (eligibility and /// alignment preconditions are the caller's responsibility). /// @param out Destination vector; results are appended. /// @param arrayDepth Requested maximum rows per @c SQLFetchScroll round-trip. @@ -1085,14 +1085,14 @@ template concept SqlRowBindableColumn = SqlNativeRowBindableValue || SqlOptionalRowBindable; /// @brief A column usable on the native row-wise array-FETCH fast path. Intentionally identical to the -/// write-side @ref SqlRowBindableColumn — the set of types we can bind row-wise into a record block on +/// write-side @c SqlRowBindableColumn — the set of types we can bind row-wise into a record block on /// fetch matches the set we can bind row-wise as a parameter array on execute: fixed-width primitives, /// date/time/datetime, numeric, char-based fixed-capacity strings, and non-numeric optionals of those. /// /// Char fixed strings are materialized by a dedicated SQL_C_CHAR bind plus a per-row length/trim fixup -/// (see @ref BindRowWiseOutputColumn / @ref FinalizeRowWiseOutputColumn); on PostgreSQL, whose driver +/// (see @c BindRowWiseOutputColumn / @c FinalizeRowWiseOutputColumn); on PostgreSQL, whose driver /// transcodes SQL_C_CHAR through the client codepage, records carrying one fall back to the per-row -/// (wide) path instead — see @ref SqlConnection::RoundTripsNarrowTextByteExact. Growable strings/binary, +/// (wide) path instead — see @c SqlConnection::RoundTripsNarrowTextByteExact. Growable strings/binary, /// GUID and variant are not row-bindable and make the whole record fall back to the per-row fetch path. template concept SqlRowWiseFetchableColumn = SqlRowBindableColumn; diff --git a/src/tests/DataMapper/RowWiseFetchTests.cpp b/src/tests/DataMapper/RowWiseFetchTests.cpp index 7ed77f18f..6a4075b47 100644 --- a/src/tests/DataMapper/RowWiseFetchTests.cpp +++ b/src/tests/DataMapper/RowWiseFetchTests.cpp @@ -214,7 +214,7 @@ TEST_CASE_METHOD(SqlTestFixture, // Two full blocks plus a partial last block proves the grow-and-rebind loop and the trim of the // final partial block (rowcount % depth != 0). constexpr auto depth = detail::kDefaultRowArrayFetchDepth; - auto const total = static_cast(depth * 2 + 7); + auto const total = static_cast((depth * 2) + 7); auto seed = std::vector {}; for (auto const i: std::views::iota(1, total + 1)) @@ -232,9 +232,9 @@ TEST_CASE_METHOD(SqlTestFixture, for (auto const& r: records) byId.emplace(r.id.Value(), &r); CHECK(byId[1]->big.Value() == 1); - CHECK(byId[static_cast(depth)]->big.Value() == static_cast(depth)); - CHECK(byId[static_cast(depth) + 1]->big.Value() == static_cast(depth) + 1); - CHECK(byId[total]->big.Value() == total); + CHECK(std::cmp_equal(byId[static_cast(depth)]->big.Value(), depth)); + CHECK(std::cmp_equal(byId[static_cast(depth) + 1]->big.Value(), depth + 1)); + CHECK(std::cmp_equal(byId[total]->big.Value(), total)); } TEST_CASE_METHOD(SqlTestFixture, "RowWiseFetch: empty result set", "[DataMapper][rowwisefetch]") From 5b66dc1b7f82d138134b3a174fd3af869cdaedfd Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Mon, 22 Jun 2026 18:01:36 +0200 Subject: [PATCH 4/6] [SqlStatement] Add transparent block-prefetch for classic per-row fetch loops Classic result iteration (while(cursor.FetchRow()){ GetColumn(i) }, bound output columns, SqlRowIterator, SqlVariantRowCursor) issued one SQLFetch per row -- one network round-trip per row, which dominates wall-clock for large result sets on TCP backends. The recent row-wise array fetch only sped up the materializing DataMapper paths (All/Range/ First); the lazy cursor loops were left on the per-row path and cannot be rewritten across all client code. Back the classic cursor transparently with the existing RowArrayCursor: on the first fetch of an eligible result set the statement arms a block buffer (SQL_ATTR_ROW_ARRAY_SIZE) and serves FetchRow()/GetColumn() and bound-column scatters from it, cutting round-trips from N to ceil(N/depth). On by default; depth is a single connection-level knob (SqlConnection::SetDefaultPrefetchDepth, default PrefetchDepthDefault = 1000; <= 1 disables). Capability-gated by SupportsNativeRowArrayFetch(). Eligibility is restricted to fixed-width numeric, temporal and GUID columns, whose block reconstruction is byte-identical to the per-row binder on every backend. Result sets carrying character/text, NUMERIC, TIME, binary or LOB columns transparently stay on the per-row path (faithful materialization of those is not uniform across backends: MSSQL returns narrow text in the client codepage, SQLite's dynamic typing reports unreliable text sizes). A new SqlLogger::OnFetchBlock hook makes the round-trip reduction observable/testable. Performance: round-trips drop ~1000x at depth 1000 for eligible sets; no regression on the per-row path (no allocation when disabled/ineligible). Risk: an active cursor reads ahead up to one block (a few MB, budget- clamped); the connection knob is the global escape hatch. Tested: sqlite3, mssql2022 (Docker), postgres (Docker 16.4) -- full suite shows no regression vs the pre-change baseline; new [prefetch] suite green on all three. Build clean under clangcl-debug (PEDANTIC /WX). Signed-off-by: Christian Parpart --- docs/best-practices.md | 19 + docs/usage.md | 32 ++ src/Lightweight/SqlConnectInfo.hpp | 17 + src/Lightweight/SqlConnection.cpp | 16 +- src/Lightweight/SqlConnection.hpp | 16 + src/Lightweight/SqlLogger.hpp | 9 + src/Lightweight/SqlStatement.cpp | 403 +++++++++++++++++- src/Lightweight/SqlStatement.hpp | 391 ++++++++++++++++- src/tests/CMakeLists.txt | 1 + src/tests/DataBinderTests.cpp | 4 + src/tests/SqlStatementPrefetchTests.cpp | 535 ++++++++++++++++++++++++ 11 files changed, 1439 insertions(+), 4 deletions(-) create mode 100644 src/tests/SqlStatementPrefetchTests.cpp diff --git a/docs/best-practices.md b/docs/best-practices.md index 4e052e3c5..588594775 100644 --- a/docs/best-practices.md +++ b/docs/best-practices.md @@ -106,6 +106,25 @@ single response. This will help to reduce the response time and the load on the server, and improve the performance of your application. +### Let block-prefetch cut network round-trips + +Per-row fetch loops issue one `SQLFetch` (one network round-trip) per row. Lightweight transparently +fetches rows in blocks (ODBC row-array binding) so a large result set costs `ceil(rows / depth)` +round-trips instead of one per row — see [Transparent block-prefetch](usage.md). It is on by default +(`Lightweight::PrefetchDepthDefault`, 1000 rows) and tuned per connection: + +```cpp +connection.SetDefaultPrefetchDepth(1000); // rows per SQLFetchScroll round-trip; <= 1 disables +``` + +Keep in mind: + +- It engages only for **fixed-width numeric/temporal** result sets; result sets with character, + `GUID`, `NUMERIC`, `TIME`, binary or LOB columns transparently stay on the per-row path. +- An active cursor reads ahead up to one block and holds a few MB of buffers, so set the depth to `1` + on a connection used for cursors you intend to abandon early or where memory is tight. +- It does not change results — values are identical to the per-row path. + ## SQL Server Variation Challenges ### 64-bit Integer Handling in Oracle Database diff --git a/docs/usage.md b/docs/usage.md index 8bd43aece..fa91414ff 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -32,6 +32,38 @@ while (stmt.FetchRow()) } ``` +## Transparent block-prefetch (fewer network round-trips) + +Classic per-row fetch loops like the one above issue **one `SQLFetch` per row**, i.e. one network +round-trip per row. On TCP-backed drivers (Microsoft SQL Server, PostgreSQL) that latency dominates the +wall-clock time of large result sets. + +Lightweight transparently reduces these round-trips: on the first `FetchRow()` of a result set it +inspects the columns and, when eligible, fetches whole **blocks** of rows per `SQLFetchScroll` +round-trip (ODBC row-array binding) and serves your `FetchRow()` / `GetColumn()` calls from that +buffer. **No code change is required** — the loops above, `SqlRowIterator`, `SqlVariantRowCursor` +and the `DataMapper` all benefit automatically. + +The depth is a connection-level setting (default `Lightweight::PrefetchDepthDefault`, 1000 rows). A +value `<= 1` disables prefetch and restores one `SQLFetch` per row: + +```cpp +auto conn = SqlConnection {}; +conn.SetDefaultPrefetchDepth(2000); // request up to 2000 rows per SQLFetchScroll round-trip +conn.SetDefaultPrefetchDepth(1); // disable prefetch for this connection +``` + +Prefetch engages only for result sets whose columns are **fixed-width numeric, temporal, or `GUID`** +types (integers, floating point, `DATE`, `TIMESTAMP`/`DATETIME`, and native `GUID`/`uniqueidentifier`/ +`uuid`) on drivers that support native row-array fetching (Microsoft SQL Server, PostgreSQL, SQLite). +Result sets that contain character/text, `NUMERIC`/`DECIMAL`, `TIME`, binary or LOB columns transparently +keep the per-row path: faithful block reconstruction of those is not achievable uniformly across backends +(e.g. Microsoft SQL Server returns narrow text in the client codepage rather than UTF-8, and SQLite's +dynamic typing reports text/`NUMERIC` columns with an unreliable, unenforced size), so the dedicated +single-row binders handle them. Memory is bounded to a few MB per active cursor (the depth is auto-clamped +to that budget), and prefetch reads ahead up to one block, so a loop that stops early over-reads at most +one block. + ## Prepared Statements You can also use prepared statements to execute queries, for example diff --git a/src/Lightweight/SqlConnectInfo.hpp b/src/Lightweight/SqlConnectInfo.hpp index 659f93243..7276e0e73 100644 --- a/src/Lightweight/SqlConnectInfo.hpp +++ b/src/Lightweight/SqlConnectInfo.hpp @@ -5,6 +5,7 @@ #include "Api.hpp" #include +#include #include #include #include @@ -13,6 +14,15 @@ namespace Lightweight { +/// @brief Default block-prefetch depth for new connections: the number of rows a classic per-row +/// fetch loop requests per @c SQLFetchScroll round-trip on the transparent prefetch path. +/// +/// Suffixed (not @c DefaultPrefetchDepth) so it does not collide with the +/// @c SqlConnection::DefaultPrefetchDepth() accessor. A connection's depth can be overridden via +/// @c SqlConnection::SetDefaultPrefetchDepth or @ref SqlConnectionDataSource::defaultPrefetchDepth; +/// a value <= 1 disables prefetch. +constexpr std::size_t PrefetchDepthDefault = 1000; + /// Represents an ODBC connection string. struct SqlConnectionString { @@ -62,6 +72,13 @@ struct [[nodiscard]] SqlConnectionDataSource std::string password; /// The connection timeout duration. std::chrono::seconds timeout { 5 }; + /// @brief Default block-prefetch depth applied to statements created on the resulting connection + /// (rows requested per @c SQLFetchScroll round-trip on the transparent per-row fetch path). + /// + /// A value <= 1 disables prefetch (every classic loop keeps issuing one @c SQLFetch per row). + /// Defaults to @ref PrefetchDepthDefault. Has effect only on backends whose driver supports + /// native row-array fetching (see @c SqlConnection::SupportsNativeRowArrayFetch). + std::size_t defaultPrefetchDepth = PrefetchDepthDefault; /// Constructs a SqlConnectionDataSource from the given connection string. LIGHTWEIGHT_API static SqlConnectionDataSource FromConnectionString(SqlConnectionString const& value); diff --git a/src/Lightweight/SqlConnection.cpp b/src/Lightweight/SqlConnection.cpp index 9fb9c0cc3..965f2a061 100644 --- a/src/Lightweight/SqlConnection.cpp +++ b/src/Lightweight/SqlConnection.cpp @@ -54,7 +54,9 @@ struct SqlConnection::Data std::chrono::steady_clock::time_point lastUsed; // Last time the connection was used (mostly interesting for // idle connections in the connection pool). SqlConnectionString connectionString; - std::unique_ptr asyncBackend; // Async execution backend (null until EnableAsync()). + std::unique_ptr asyncBackend; // Async execution backend (null until EnableAsync()). + std::size_t defaultPrefetchDepth = PrefetchDepthDefault; // Rows requested per SQLFetchScroll on the + // transparent per-row prefetch path (<= 1 disables). }; SqlConnection::SqlConnection(): @@ -173,6 +175,16 @@ std::chrono::steady_clock::time_point SqlConnection::LastUsed() const noexcept return m_data->lastUsed; } +std::size_t SqlConnection::DefaultPrefetchDepth() const noexcept +{ + return m_data->defaultPrefetchDepth; +} + +void SqlConnection::SetDefaultPrefetchDepth(std::size_t depth) noexcept +{ + m_data->defaultPrefetchDepth = depth; +} + void SqlConnection::EnableAsync(Async::IExecutor& dbWorkers, Async::IResumeScheduler& resume) { // TODO(async): once the native event backend lands, select it here via a per-connection @@ -219,6 +231,8 @@ bool SqlConnection::Connect(SqlConnectionDataSource const& info) noexcept ZoneScopedN("SqlConnection::Connect(DataSource)"); EnsureHandlesAllocated(); + m_data->defaultPrefetchDepth = info.defaultPrefetchDepth; + if (m_hDbc) SQLDisconnect(m_hDbc); diff --git a/src/Lightweight/SqlConnection.hpp b/src/Lightweight/SqlConnection.hpp index 25dca672e..220fb27d0 100644 --- a/src/Lightweight/SqlConnection.hpp +++ b/src/Lightweight/SqlConnection.hpp @@ -184,6 +184,22 @@ class SqlConnection final /// @return `true` if narrow character data round-trips byte-exact on that backend. [[nodiscard]] static bool RoundTripsNarrowTextByteExact(SqlServerType serverType) noexcept; + /// @brief The default block-prefetch depth applied to statements created on this connection. + /// + /// Classic per-row fetch loops (`while (cursor.FetchRow()) ...`, @c SqlRowIterator, + /// @c SqlVariantRowCursor) transparently request this many rows per @c SQLFetchScroll round-trip + /// instead of issuing one @c SQLFetch per row. A value <= 1 disables prefetch. Effective only when + /// @ref SupportsNativeRowArrayFetch is true; otherwise statements fall back to per-row fetching. + /// + /// @return The configured default prefetch depth (defaults to @ref PrefetchDepthDefault). + [[nodiscard]] LIGHTWEIGHT_API std::size_t DefaultPrefetchDepth() const noexcept; + + /// @brief Sets the default block-prefetch depth for statements created on this connection. + /// + /// @param depth Rows to request per @c SQLFetchScroll round-trip on the transparent prefetch path; + /// a value <= 1 disables prefetch (restoring one @c SQLFetch per row). + LIGHTWEIGHT_API void SetDefaultPrefetchDepth(std::size_t depth) noexcept; + /// Creates a new query builder for the given table, compatible with the current connection. /// /// @param table The table to query. diff --git a/src/Lightweight/SqlLogger.hpp b/src/Lightweight/SqlLogger.hpp index 6930f919f..a12c1f4c6 100644 --- a/src/Lightweight/SqlLogger.hpp +++ b/src/Lightweight/SqlLogger.hpp @@ -113,6 +113,15 @@ class SqlLogger /// Invoked when a row is fetched. virtual void OnFetchRow() = 0; + /// @brief Invoked once per block-prefetch round-trip (one @c SQLFetchScroll that materialized a + /// whole row block on the transparent prefetch path), with the number of rows the block yielded. + /// + /// Non-pure with an empty default so existing loggers need no change; override to observe how many + /// network round-trips a query actually cost (N rows at depth D collapse to @c ceil(N/D) blocks). + /// + /// @param rowsInBlock Number of rows materialized by this block fetch (0 at end of result set). + virtual void OnFetchBlock(std::size_t /*rowsInBlock*/) {} + /// Invoked when fetching is done. virtual void OnFetchEnd() = 0; diff --git a/src/Lightweight/SqlStatement.cpp b/src/Lightweight/SqlStatement.cpp index fe9d4cb39..a4aec5fd0 100644 --- a/src/Lightweight/SqlStatement.cpp +++ b/src/Lightweight/SqlStatement.cpp @@ -10,9 +10,13 @@ #include #include +#include #include #include #include +#include +#include +#include #include #include #include @@ -31,6 +35,24 @@ struct SqlStatement::Data std::vector> postExecuteCallbacks; std::vector> postProcessOutputColumnCallbacks; + /// @brief Lifecycle of the transparent block-prefetch on the current result set. + enum class PrefetchMode : std::uint8_t + { + Unarmed, //!< not yet decided (before the first FetchRow of a result set) + Active, //!< serving rows from the RowArrayCursor block buffer + Disabled, //!< per-row SQLFetch path (prefetch off, or result set ineligible) + }; + PrefetchMode prefetchMode = PrefetchMode::Unarmed; + // unique_ptr keeps the cursor's address stable: RowArrayCursor is non-movable and binds raw + // pointers into its own members, so it must never be relocated after construction. + std::unique_ptr prefetch; + std::size_t prefetchBlockRows = 0; // rows materialized by the last FetchArray() + std::size_t prefetchRowInBlock = 0; // 0-based offset of the current logical row within that block + std::vector> prefetchScatters; // copy current block cell -> bound output + std::vector> prefetchDeferredBinds; // real SQLBindCol thunks for the fallback + bool prefetchBindingUnsupported = false; // a bound output column's target type cannot be served + // from the block buffer, so the set keeps the per-row path + static Data const NoData; }; @@ -184,6 +206,12 @@ SqlStatement::SqlStatement(): .batchStagingBuffers = {}, .postExecuteCallbacks = {}, .postProcessOutputColumnCallbacks = {}, + .prefetchMode = Data::PrefetchMode::Unarmed, + .prefetch = {}, + .prefetchBlockRows = 0, + .prefetchRowInBlock = 0, + .prefetchScatters = {}, + .prefetchDeferredBinds = {}, }, [](Data* data) { @@ -240,7 +268,8 @@ SqlStatement::SqlStatement(SqlConnection& relatedConnection): } SqlStatement::SqlStatement(std::nullopt_t /*nullopt*/): - m_data { const_cast(&Data::NoData), [](Data* /*data*/) {} } + m_data { const_cast(&Data::NoData), [](Data* /*data*/) { + } } { } @@ -437,6 +466,266 @@ size_t SqlStatement::LastInsertId(std::string_view tableName) return ExecuteDirectScalar(Query(tableName).LastInsertId()).value_or(0); } +namespace +{ + /// Sentinel for "no current row yet" in the block-prefetch row cursor (before the first row of a + /// result set is served). + constexpr std::size_t NoPrefetchRow = (std::numeric_limits::max)(); + + /// @brief Whether a column's reported SQL type maps to a representation the block-prefetch path can + /// serve faithfully on the current backend — the natural-mapping allowlist. + /// + /// The fixed-width classes (integer, floating-point, DATE, TIMESTAMP) and @c SQL_GUID are bound by + /// @ref RowArrayCursor as a dedicated, encoding-independent C representation (@c Int64 / @c Double / + /// @c Date / @c Timestamp / @c Guid) that @ref SqlStatement::ConvertCell reconstructs exactly on every + /// backend. + /// + /// Character columns deliberately keep the per-row @c SQLFetch path. Faithful block reconstruction of + /// text is not achievable uniformly across the supported backends: MS SQL Server's narrow reads return + /// the client codepage (not UTF-8) with a target-width-dependent representation, and SQLite's dynamic + /// typing reports text columns with an unreliable (often too-small, and unenforced) column size, which + /// would truncate a longer value during a bulk fetch. NUMERIC/DECIMAL (which several drivers surface as + /// text), TIME, binary and LOB columns likewise keep the per-row path, where the dedicated single-row + /// binders already handle their representation. See docs/best-practices.md. + /// @param sqlType The @c SQL_* type code reported by @c SQLDescribeCol. + constexpr bool PrefetchableSqlType(SQLSMALLINT sqlType) noexcept + { + switch (sqlType) + { + case SQL_BIT: + case SQL_TINYINT: + case SQL_SMALLINT: + case SQL_INTEGER: + case SQL_BIGINT: + case SQL_REAL: + case SQL_FLOAT: + case SQL_DOUBLE: + case SQL_TYPE_DATE: + case SQL_DATE: + case SQL_TYPE_TIMESTAMP: + case SQL_TIMESTAMP: + case SQL_GUID: + return true; + default: + return false; + } + } +} // namespace + +std::size_t SqlStatement::EffectivePrefetchDepth() const noexcept +{ + // Gate the connection's configured depth by the driver's row-array capability; on backends that do + // not honour SQL_ATTR_ROW_ARRAY_SIZE this collapses to 1 (disabled), so the per-row path is kept. + if (!m_connection || !m_connection->SupportsNativeRowArrayFetch()) + return 1; + return m_connection->DefaultPrefetchDepth(); +} + +bool SqlStatement::IsPrefetchActive() const noexcept +{ + return m_data->prefetchMode == Data::PrefetchMode::Active; +} + +RowArrayCursor const& SqlStatement::PrefetchCursorRef() const noexcept +{ + return *m_data->prefetch; +} + +std::size_t SqlStatement::PrefetchRowInBlock() const noexcept +{ + return m_data->prefetchRowInBlock; +} + +bool SqlStatement::ShouldRecordPrefetchBinding() const noexcept +{ + // Record scatter/deferred closures (rather than binding immediately) whenever prefetch is enabled + // and has not already fallen back to the per-row path for this result set. + return EffectivePrefetchDepth() > 1 && m_data->prefetchMode != Data::PrefetchMode::Disabled; +} + +void SqlStatement::ResetPrefetchBindings() noexcept +{ + m_data->prefetchScatters.clear(); + m_data->prefetchDeferredBinds.clear(); + m_data->prefetchBindingUnsupported = false; +} + +void SqlStatement::MarkPrefetchBindingUnsupported() noexcept +{ + m_data->prefetchBindingUnsupported = true; +} + +void SqlStatement::RecordPrefetchColumn(SQLUSMALLINT column, + std::function scatter, + std::function deferredBind) +{ + // Slots are indexed by (column - 1); re-binding a column overwrites its slot instead of appending. + // The two vectors are resized independently: once prefetch is armed Active the deferred-bind vector + // is emptied (its thunks are unused on the fast path) while the scatter vector is retained, so they + // can legitimately differ in size when a column is re-bound mid-iteration. + auto const index = static_cast(column - 1); + if (m_data->prefetchScatters.size() <= index) + m_data->prefetchScatters.resize(index + 1); + if (m_data->prefetchDeferredBinds.size() <= index) + m_data->prefetchDeferredBinds.resize(index + 1); + m_data->prefetchScatters[index] = std::move(scatter); + m_data->prefetchDeferredBinds[index] = std::move(deferredBind); +} + +void SqlStatement::ResetPrefetchState() noexcept +{ + m_data->prefetch.reset(); // dtor restores SQL_ATTR_ROW_ARRAY_SIZE/bind state on the handle + m_data->prefetchScatters.clear(); + m_data->prefetchDeferredBinds.clear(); + m_data->prefetchBlockRows = 0; + m_data->prefetchRowInBlock = NoPrefetchRow; + m_data->prefetchBindingUnsupported = false; + m_data->prefetchMode = Data::PrefetchMode::Unarmed; +} + +void SqlStatement::ArmPrefetchOnFirstFetch() noexcept +{ + if (m_data->prefetchMode != Data::PrefetchMode::Unarmed) + return; + + // Disable the fast path: keep the deferred real-bind thunks (a bound-column loop relies on them and + // they are flushed by TryFetchRow, which can surface a bind error), but drop the unused scatters. + auto const disablePrefetch = [this] { + m_data->prefetchMode = Data::PrefetchMode::Disabled; + m_data->prefetchScatters.clear(); + }; + + auto const depth = EffectivePrefetchDepth(); + if (depth <= 1 || m_data->prefetchBindingUnsupported) + { + // Disabled outright, or a bound output column has a target type the block buffer cannot + // reconstruct (e.g. SqlNumeric, SqlTime, binary, or a user type) — keep the per-row path. + disablePrefetch(); + return; + } + + try + { + // Decide eligibility from result-set metadata only (SQLDescribeCol — no SQLBindCol, no cursor + // close). Constructing a RowArrayCursor just to inspect it and then destroying it would close the + // cursor (its destructor calls SQLFreeStmt(SQL_CLOSE)), breaking the per-row fallback; so the + // cursor is built only once the result set is known to be eligible. + SQLSMALLINT numColumns = 0; + if (!SQL_SUCCEEDED(SQLNumResultCols(m_hStmt, &numColumns)) || numColumns <= 0) + { + disablePrefetch(); + return; + } + for (auto const column: std::views::iota(SQLUSMALLINT(1), static_cast(numColumns + 1))) + { + SQLSMALLINT sqlType = 0; + SQLULEN columnSize = 0; + SQLSMALLINT decimalDigits = 0; + SQLSMALLINT nullable = 0; + SQLSMALLINT nameLen = 0; + SQLWCHAR nameBuffer[256] = {}; + if (!SQL_SUCCEEDED(SQLDescribeColW(m_hStmt, + column, + nameBuffer, + static_cast(std::size(nameBuffer)), + &nameLen, + &sqlType, + &columnSize, + &decimalDigits, + &nullable))) + { + disablePrefetch(); + return; + } + if (!PrefetchableSqlType(sqlType)) + { + disablePrefetch(); + return; + } + } + + // Eligible by type: bind the block buffers. A RowArrayCursor ctor failure here throws before it + // mutates the statement, so the catch below leaves the cursor open for the per-row fallback. + m_data->prefetch = std::make_unique(*this, depth); + m_data->prefetchBlockRows = 0; + m_data->prefetchRowInBlock = NoPrefetchRow; + m_data->prefetchMode = Data::PrefetchMode::Active; + m_data->prefetchDeferredBinds.clear(); // eligible: scatters serve reads, deferred binds unused + } + catch (...) + { + // The RowArrayCursor ctor declined the set (RowArrayCursorUnsupported) or failed mid-setup. Drop + // any cursor and defensively restore single-row attributes WITHOUT closing the cursor, so the + // per-row path still sees the open result set. + m_data->prefetch.reset(); + SQLFreeStmt(m_hStmt, SQL_UNBIND); + SQLSetStmtAttr(m_hStmt, SQL_ATTR_ROW_ARRAY_SIZE, OdbcIntAttr(1), 0); + SQLSetStmtAttr(m_hStmt, SQL_ATTR_ROW_BIND_TYPE, OdbcIntAttr(SQL_BIND_BY_COLUMN), 0); + SQLSetStmtAttr(m_hStmt, SQL_ATTR_ROW_STATUS_PTR, nullptr, 0); + SQLSetStmtAttr(m_hStmt, SQL_ATTR_ROWS_FETCHED_PTR, nullptr, 0); + disablePrefetch(); + } +} + +void SqlStatement::RequirePrefetchColumnInRange(RowArrayCursor const& cursor, SQLUSMALLINT column) const +{ + if (column == 0 || static_cast(column) > cursor.ColumnCount()) + throw std::invalid_argument { std::format( + "SqlStatement: result column index {} is out of range (1..{})", column, cursor.ColumnCount()) }; +} + +SqlVariant SqlStatement::MakePrefetchVariantCell(RowArrayCursor const& cursor, std::size_t row, SQLUSMALLINT column) const +{ + // Mirror SqlDataBinder::GetColumn's alternative selection so a prefetched SqlVariant + // compares equal to one read on the per-row path. Only the prefetch allowlist's SQL types reach + // here (binary/LOB/TIME/NUMERIC keep the per-row binder), so the switch is intentionally narrow. + if (cursor.IsCellNull(row, column)) + return SqlVariant { SqlNullValue }; + + auto const serverType = m_connection ? m_connection->ServerType() : SqlServerType::UNKNOWN; + + switch (cursor.ColumnSqlType(column)) + { + case SQL_BIT: + return SqlVariant { SqlVariant::InnerType { static_cast(cursor.GetI64(row, column).value_or(0)) } }; + case SQL_TINYINT: + return SqlVariant { SqlVariant::InnerType { static_cast(cursor.GetI64(row, column).value_or(0)) } }; + case SQL_SMALLINT: + return SqlVariant { SqlVariant::InnerType { static_cast(cursor.GetI64(row, column).value_or(0)) } }; + case SQL_INTEGER: + return SqlVariant { SqlVariant::InnerType { static_cast(cursor.GetI64(row, column).value_or(0)) } }; + case SQL_BIGINT: + return SqlVariant { SqlVariant::InnerType { static_cast(cursor.GetI64(row, column).value_or(0)) } }; + case SQL_REAL: + return SqlVariant { SqlVariant::InnerType { static_cast(cursor.GetF64(row, column).value_or(0.0)) } }; + case SQL_FLOAT: + case SQL_DOUBLE: + return SqlVariant { SqlVariant::InnerType { cursor.GetF64(row, column).value_or(0.0) } }; + case SQL_CHAR: + case SQL_VARCHAR: + case SQL_WCHAR: + case SQL_WVARCHAR: { + auto text = cursor.GetString(row, column).value_or(std::string {}); + // The SQLite driver surfaces GUID columns as (W)VARCHAR; match the binder's heuristic. + if (serverType == SqlServerType::SQLITE) + if (auto const maybeGuid = SqlGuid::TryParse(text); maybeGuid) + return SqlVariant { SqlVariant::InnerType { *maybeGuid } }; + return SqlVariant { SqlVariant::InnerType { std::move(text) } }; + } + case SQL_TYPE_DATE: + case SQL_DATE: + return SqlVariant { SqlVariant::InnerType { cursor.GetDate(row, column).value_or(SqlDate {}) } }; + case SQL_TYPE_TIMESTAMP: + case SQL_TIMESTAMP: + return SqlVariant { SqlVariant::InnerType { cursor.GetTimestamp(row, column).value_or(SqlDateTime {}) } }; + case SQL_GUID: + return SqlVariant { SqlVariant::InnerType { cursor.GetGuid(row, column).value_or(SqlGuid {}) } }; + default: + // Unreachable: arming gates the prefetch path to the SQL types above. + return SqlVariant { SqlNullValue }; + } +} + // Fetches the next row of the result set. bool SqlStatement::FetchRow() { @@ -452,8 +741,101 @@ bool SqlStatement::FetchRow() throw SqlException(errorInfo); } +std::expected SqlStatement::FetchRowPrefetched() noexcept +{ + // Advance to the next logical row. prefetchRowInBlock holds the *current* row (NoPrefetchRow before + // the first fetch), so GetColumn/scatters can read it while inside the caller's loop body. + auto const nextRow = m_data->prefetchRowInBlock == NoPrefetchRow ? std::size_t { 0 } : m_data->prefetchRowInBlock + 1; + + if (nextRow >= m_data->prefetchBlockRows) + { + // The current block is exhausted: pull the next one with a single SQLFetchScroll round-trip. + try + { + m_data->prefetchBlockRows = m_data->prefetch->FetchArray(); + } + catch (SqlException const& error) + { + return MakeUnexpected(error.info(), std::source_location::current()); + } + catch (...) + { + return MakeUnexpected(LastError(), std::source_location::current()); + } + SqlLogger::GetLogger().OnFetchBlock(m_data->prefetchBlockRows); + if (m_data->prefetchBlockRows == 0) + { + // End of result set. Drop the array binding now and switch to Disabled so a stray fetch after + // EOF takes the (closed-cursor) per-row path rather than re-arming on a dead handle. The full + // reset to Unarmed happens in CloseCursor when the result cursor is torn down. + m_data->prefetch.reset(); + m_data->prefetchScatters.clear(); + m_data->prefetchDeferredBinds.clear(); + m_data->prefetchRowInBlock = NoPrefetchRow; + m_data->prefetchMode = Data::PrefetchMode::Disabled; + SQLCloseCursor(m_hStmt); + m_data->postProcessOutputColumnCallbacks.clear(); + SqlLogger::GetLogger().OnFetchEnd(); + return false; + } + m_data->prefetchRowInBlock = 0; + } + else + { + m_data->prefetchRowInBlock = nextRow; + } + + // Copy the current block row into any bound output columns (the bound-column loop scatters). + try + { + for (auto const& scatter: m_data->prefetchScatters) + if (scatter) + scatter(); + } + catch (SqlException const& error) + { + return MakeUnexpected(error.info(), std::source_location::current()); + } + catch (...) + { + return MakeUnexpected(LastError(), std::source_location::current()); + } + SqlLogger::GetLogger().OnFetchRow(); + return true; +} + std::expected SqlStatement::TryFetchRow(std::source_location location) noexcept { + if (m_data->prefetchMode == Data::PrefetchMode::Unarmed) + ArmPrefetchOnFirstFetch(); + + if (m_data->prefetchMode == Data::PrefetchMode::Active) + return FetchRowPrefetched(); + + // Disabled (prefetch off, or the result set was ineligible). A bound-column loop deferred its real + // SQLBindCol calls pending that decision; issue them once now so this and every subsequent SQLFetch + // materializes into the caller's bound storage. A bind error surfaces here so FetchRow throws as it + // would have at BindOutputColumns time on the non-prefetch path. + if (!m_data->prefetchDeferredBinds.empty()) + { + auto deferredBinds = std::move(m_data->prefetchDeferredBinds); + m_data->prefetchDeferredBinds.clear(); + try + { + for (auto const& deferredBind: deferredBinds) + if (deferredBind) + deferredBind(); + } + catch (SqlException const& error) + { + return MakeUnexpected(error.info(), location); + } + catch (...) + { + return MakeUnexpected(LastError(), location); + } + } + auto const sqlResult = SQLFetch(m_hStmt); switch (sqlResult) { @@ -571,6 +953,7 @@ RowArrayCursor::RowArrayCursor(SqlStatement& stmt, std::size_t arrayDepth): &nullable)); BoundColumn boundColumn; + boundColumn.sqlType = sqlType; if (IsIntegerSqlType(sqlType)) { boundColumn.type = BoundType::Int64; @@ -750,6 +1133,24 @@ std::size_t RowArrayCursor::ColumnCount() const noexcept return m_columns.size(); } +RowArrayCursor::BoundType RowArrayCursor::ColumnBoundType(SQLUSMALLINT column) const +{ + return m_columns.at(column - 1).type; +} + +SQLSMALLINT RowArrayCursor::ColumnSqlType(SQLUSMALLINT column) const +{ + return m_columns.at(column - 1).sqlType; +} + +bool RowArrayCursor::IsCellNull(std::size_t rowInBatch, SQLUSMALLINT column) const +{ + if (rowInBatch >= m_lastFetched) + throw std::out_of_range { std::format( + "RowArrayCursor: rowInBatch {} >= rowsFetched {}", rowInBatch, m_lastFetched) }; + return m_columns.at(column - 1).indicators[rowInBatch] == SQL_NULL_DATA; +} + std::size_t RowArrayCursor::ArrayDepth() const noexcept { return m_arrayDepth; diff --git a/src/Lightweight/SqlStatement.hpp b/src/Lightweight/SqlStatement.hpp index 6e5dcd423..e4aaf6b87 100644 --- a/src/Lightweight/SqlStatement.hpp +++ b/src/Lightweight/SqlStatement.hpp @@ -12,6 +12,9 @@ #include "DataBinder/SqlDateTime.hpp" #include "DataBinder/SqlFixedString.hpp" #include "DataBinder/SqlGuid.hpp" +#include "DataBinder/SqlNumeric.hpp" +#include "DataBinder/StringInterface.hpp" +#include "DataBinder/UnicodeConverter.hpp" #include "SqlConnection.hpp" #include "SqlQuery.hpp" #include "SqlQueryFormatter.hpp" @@ -21,8 +24,10 @@ #include #include +#include #include #include +#include #include #include #include @@ -389,6 +394,64 @@ class [[nodiscard]] SqlStatement final: public SqlDataBinderCallback LIGHTWEIGHT_API void RequireIndicators(); LIGHTWEIGHT_API SQLLEN* GetIndicatorForColumn(SQLUSMALLINT column) noexcept; + // --- Transparent block-prefetch: backs the classic per-row fetch loops (FetchRow + GetColumn, + // bound output columns, SqlRowIterator, SqlVariantRowCursor) with the existing RowArrayCursor so a + // whole block of rows is materialized per SQLFetchScroll round-trip instead of one SQLFetch per row. + // Out-of-line accessors because the prefetch state lives in the opaque Data struct. + + /// @return The effective prefetch depth: the connection default gated by the driver's row-array + /// capability (1 — i.e. disabled — when unsupported or the connection default is <= 1). + [[nodiscard]] std::size_t EffectivePrefetchDepth() const noexcept; + /// @brief Arms (or disables) block-prefetch on the first fetch of a result set; idempotent. + void ArmPrefetchOnFirstFetch() noexcept; + /// @brief Fetches the next logical row from the block buffer, refilling the block and running the + /// recorded bound-column scatters as needed. @return true if a row is available. + [[nodiscard]] std::expected FetchRowPrefetched() noexcept; + /// @return Whether block-prefetch is currently materializing this result set. + [[nodiscard]] LIGHTWEIGHT_API bool IsPrefetchActive() const noexcept; + /// @return The active block-prefetch cursor (precondition: @ref IsPrefetchActive). + [[nodiscard]] LIGHTWEIGHT_API RowArrayCursor const& PrefetchCursorRef() const noexcept; + /// @return The 0-based offset of the current logical row within the last fetched block. + [[nodiscard]] LIGHTWEIGHT_API std::size_t PrefetchRowInBlock() const noexcept; + /// @return Whether @ref BindOutputColumns should record scatter/deferred-bind closures (prefetch is + /// enabled and not yet disabled) instead of issuing @c SQLBindCol immediately. + [[nodiscard]] LIGHTWEIGHT_API bool ShouldRecordPrefetchBinding() const noexcept; + /// @brief Drops any previously recorded scatter/deferred-bind closures (for idempotent re-binding). + LIGHTWEIGHT_API void ResetPrefetchBindings() noexcept; + /// @brief Flags that a bound output column's target type cannot be served from the block buffer, so + /// arming must decline prefetch for this result set and keep the per-row path. + LIGHTWEIGHT_API void MarkPrefetchBindingUnsupported() noexcept; + /// @brief Records, for one output column, the per-row scatter closure (copies the current block cell + /// into the bound destination) and the real @c SQLBindCol thunk used if the result set turns out + /// prefetch-ineligible. Indexed by @p column so re-binding the same column overwrites rather than + /// appends — keeping the bound-column loop, the optional rebind idiom, and the DataMapper's per-row + /// re-binding all bounded. + /// @param column 1-based output column index. + /// @param scatter Copies the current block cell into the bound destination. + /// @param deferredBind Issues the real @c SQLBindCol when the fast path is declined. + LIGHTWEIGHT_API void RecordPrefetchColumn(SQLUSMALLINT column, + std::function scatter, + std::function deferredBind); + /// @brief Tears down all block-prefetch state, restoring the handle to single-row fetching. + LIGHTWEIGHT_API void ResetPrefetchState() noexcept; + /// @brief Builds an @c SqlVariant cell from the block buffer, mirroring @c SqlDataBinder. + [[nodiscard]] LIGHTWEIGHT_API SqlVariant MakePrefetchVariantCell(RowArrayCursor const& cursor, + std::size_t row, + SQLUSMALLINT column) const; + /// @brief Converts a materialized block cell to the requested native type @p T. + template + [[nodiscard]] T ConvertCell(RowArrayCursor const& cursor, std::size_t row, SQLUSMALLINT column) const; + + /// @brief Validates a 1-based column index against the active prefetch cursor, throwing + /// @c std::invalid_argument for an out-of-range index — matching the per-row path's behaviour for + /// an invalid descriptor index (ODBC SQLSTATE 07009). + LIGHTWEIGHT_API void RequirePrefetchColumnInRange(RowArrayCursor const& cursor, SQLUSMALLINT column) const; + + /// @brief Records the scatter + deferred-bind closures for one bound output column @p arg of type + /// @p T (used instead of an immediate @c SQLBindCol while prefetch is pending/active). + template + void RecordPrefetchOutputColumn(SQLUSMALLINT column, T* arg); + // private data members struct Data; std::unique_ptr m_data; // The private data of the statement @@ -673,8 +736,9 @@ class [[nodiscard]] RowArrayCursor /// @return The value, or std::nullopt if the cell is NULL. [[nodiscard]] LIGHTWEIGHT_API std::optional GetGuid(std::size_t rowInBatch, SQLUSMALLINT column) const; - private: - /// How a result column is bound for bulk fetch. + /// @brief How a result column is bound for bulk fetch (the canonical fixed-stride C representation + /// chosen from the column's SQL type). Public so a transparent prefetch layer can dispatch a generic + /// cell read to the matching @c Get* accessor. enum class BoundType : std::uint8_t { Int64, //!< bound as SQL_C_SBIGINT into an int64 buffer @@ -686,10 +750,30 @@ class [[nodiscard]] RowArrayCursor Guid, //!< bound as SQL_C_GUID into a 16-byte GUID buffer }; + /// @brief The bound representation chosen for a result column. + /// @param column 1-based result column index. + /// @return The @ref BoundType the column was bound as. + [[nodiscard]] LIGHTWEIGHT_API BoundType ColumnBoundType(SQLUSMALLINT column) const; + + /// @brief The raw SQL data type the driver reported for a result column (the @c SQL_* value from + /// @c SQLDescribeCol), letting callers gate on the exact source type rather than the coarser + /// @ref BoundType (which collapses e.g. textual TIME/NUMERIC into @c Char). + /// @param column 1-based result column index. + /// @return The reported @c SQL_* type code. + [[nodiscard]] LIGHTWEIGHT_API SQLSMALLINT ColumnSqlType(SQLUSMALLINT column) const; + + /// @brief Whether a cell in the last fetched block is SQL NULL. + /// @param rowInBatch 0-based row offset within the block returned by the last @ref FetchArray. + /// @param column 1-based result column index. + /// @return @c true if the cell's length indicator is @c SQL_NULL_DATA. + [[nodiscard]] LIGHTWEIGHT_API bool IsCellNull(std::size_t rowInBatch, SQLUSMALLINT column) const; + + private: /// Per-column binding metadata + owning buffers. struct BoundColumn { BoundType type {}; //!< how this column is bound + SQLSMALLINT sqlType {}; //!< raw SQL_* type reported by SQLDescribeCol std::size_t elementWidth {}; //!< byte stride of one row's value in the buffer std::vector buffer; //!< arrayDepth * elementWidth contiguous bytes std::vector indicators; //!< arrayDepth length indicators (SQL_NULL_DATA etc.) @@ -956,6 +1040,17 @@ inline LIGHTWEIGHT_FORCE_INLINE std::string const& SqlStatement::PreparedQuery() template inline LIGHTWEIGHT_FORCE_INLINE void SqlStatement::BindOutputColumns(Args*... args) { + if (ShouldRecordPrefetchBinding()) + { + // Prefetch is pending/active: defer the SQLBindCol and instead record per-column scatters that + // copy each block cell into the caller's storage. ResetPrefetchBindings makes the optional + // rebind idiom (re-calling BindOutputColumns each row) idempotent rather than accumulating. + ResetPrefetchBindings(); + SQLUSMALLINT i = 0; + ((++i, RecordPrefetchOutputColumn(i, args)), ...); + return; + } + RequireIndicators(); SQLUSMALLINT i = 0; @@ -966,6 +1061,19 @@ template requires(((std::is_class_v && std::is_aggregate_v) && ...)) void SqlStatement::BindOutputColumnsToRecord(Records*... records) { + if (ShouldRecordPrefetchBinding()) + { + ResetPrefetchBindings(); + SQLUSMALLINT i = 0; + ((EnumerateRecordMembers(*records, + [this, &i](FieldType& value) { + ++i; + RecordPrefetchOutputColumn(i, &value); + })), + ...); + return; + } + RequireIndicators(); SQLUSMALLINT i = 0; @@ -982,6 +1090,14 @@ void SqlStatement::BindOutputColumnsToRecord(Records*... records) template inline LIGHTWEIGHT_FORCE_INLINE void SqlStatement::BindOutputColumn(SQLUSMALLINT columnIndex, T* arg) { + // Singular bind: no ResetPrefetchBindings (callers — e.g. the DataMapper — set columns one at a + // time); RecordPrefetchColumn overwrites the column's slot so per-row re-binding stays bounded. + if (ShouldRecordPrefetchBinding()) + { + RecordPrefetchOutputColumn(columnIndex, arg); + return; + } + RequireIndicators(); RequireSuccess(SqlDataBinder::OutputColumn(m_hStmt, columnIndex, arg, GetIndicatorForColumn(columnIndex), *this)); @@ -1513,6 +1629,16 @@ void SqlStatement::FetchAllRowWise(std::vector& out, std::size_t arrayDe template inline bool SqlStatement::GetColumn(SQLUSMALLINT column, T* result) const { + if (IsPrefetchActive()) + { + auto const& cursor = PrefetchCursorRef(); + auto const row = PrefetchRowInBlock(); + RequirePrefetchColumnInRange(cursor, column); + if (cursor.IsCellNull(row, column)) + return false; + *result = ConvertCell(cursor, row, column); + return true; + } SQLLEN indicator {}; // TODO: Handle NULL values if we find out that we need them for our use-cases. RequireSuccess(SqlDataBinder::GetColumn(m_hStmt, column, result, &indicator, *this)); return indicator != SQL_NULL_DATA; @@ -1524,11 +1650,258 @@ namespace detail template concept SqlNullableType = (std::same_as || IsSpecializationOf); + /// Detects @c SqlFixedString specializations (the inline fixed-capacity strings). + template + struct IsSqlFixedStringSpec: std::false_type + { + }; + template + struct IsSqlFixedStringSpec>: std::true_type + { + }; + template + concept SqlFixedStringCell = IsSqlFixedStringSpec>::value; + + /// The plain standard string flavours the block-prefetch reader converts to from UTF-8 bytes. + template + concept PlainStringCell = + std::same_as || std::same_as || std::same_as + || std::same_as || std::same_as; + + /// Detects @c SqlNumeric specializations. + template + struct IsSqlNumericSpec: std::false_type + { + }; + template + struct IsSqlNumericSpec>: std::true_type + { + }; + template + concept SqlNumericCell = IsSqlNumericSpec>::value; + + /// Views a UTF-8 @c std::string (opaque byte container) as a @c std::u8string_view for conversion. + [[nodiscard]] inline std::u8string_view AsU8View(std::string const& utf8) noexcept + { + return std::u8string_view { reinterpret_cast(utf8.data()), utf8.size() }; + } + + /// @brief Trims the trailing bytes of a fetched fixed-string value to match + /// @c SqlFixedString::PostProcessOutputColumn (which the single-row @c GetColumn path applies), so a + /// prefetched value is byte-identical to a per-row read. Every mode strips trailing NULs; + /// @c FIXED_SIZE_RIGHT_TRIMMED additionally strips trailing ASCII whitespace (e.g. @c CHAR(N) space + /// padding). Operates on the raw UTF-8 bytes before any wide conversion — ASCII whitespace/NUL are + /// single bytes that map one-to-one to their wide code units, so the result matches a trim applied + /// after conversion. + /// @tparam Mode The fixed string's @c SqlFixedStringMode (its @c PostRetrieveOperation). + /// @param bytes The fetched UTF-8 bytes, trimmed in place. + template + inline void TrimFixedStringBytes(std::string& bytes) noexcept + { + auto const isTrailingTrimmable = [](char c) noexcept { + if (c == '\0') + return true; + if constexpr (Mode == SqlFixedStringMode::FIXED_SIZE_RIGHT_TRIMMED) + return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\v' || c == '\f'; + else + return false; + }; + while (!bytes.empty() && isTrailingTrimmable(bytes.back())) + bytes.pop_back(); + } + + /// @brief Decodes the fetched UTF-8 bytes into a @c std::basic_string of the target character type + /// @p Char, reusing the project's UnicodeConverter. The block-prefetch reader stores text as UTF-8 + /// (RowArrayCursor::GetString); this re-encodes it to the string target's element type. + /// @tparam Char The target character type (@c char / @c char8_t / @c char16_t / @c char32_t / @c wchar_t). + /// @param utf8 The fetched UTF-8 bytes. + /// @return The decoded string in the target encoding. + template + [[nodiscard]] inline std::basic_string DecodeUtf8To(std::string const& utf8) + { + if constexpr (std::same_as) + return utf8; + else if constexpr (std::same_as) + return std::u8string { AsU8View(utf8) }; + else if constexpr (std::same_as) + return ToUtf16(AsU8View(utf8)); + else if constexpr (std::same_as) + return ToUtf32(AsU8View(utf8)); + else + return ToStdWideString(AsU8View(utf8)); + } + + /// @brief Any string-like target the block-prefetch reader reconstructs from UTF-8 bytes: the plain + /// standard strings plus the Lightweight string wrappers (fixed- and dynamic-capacity). Each exposes a + /// @c value_type and is constructible from a @c std::basic_string of that type. + template + concept StringLikeCell = PlainStringCell || SqlStringInterface; + + /// A scalar target type the block-prefetch reader can reconstruct faithfully (mirrors the non-throwing + /// branches of @c SqlStatement::ConvertCell). Excludes types whose faithful reconstruction needs the + /// dedicated single-row binder (e.g. @c SqlNumeric, @c SqlTime, binary, user types). + template + concept PrefetchConvertibleScalar = + std::same_as || std::same_as || std::same_as || std::same_as + || StringLikeCell || std::is_floating_point_v || std::is_integral_v || std::is_enum_v; + + template + struct PrefetchConvertibleOptional: std::false_type + { + }; + template + struct PrefetchConvertibleOptional>: std::bool_constant> + { + }; + + /// A bound output target the prefetch scatter can serve: a convertible scalar or an optional of one. + template + concept PrefetchConvertible = PrefetchConvertibleScalar || PrefetchConvertibleOptional::value; + + /// @brief Reconstructs a temporal or GUID cell from the block buffer. Each target reads its matching + /// bound representation; a mismatched bound type (only reachable via a cross-type @c GetColumn) yields + /// a default, mirroring the lenient single-row path. A GUID stored as text (SQLite) is parsed back. + template + [[nodiscard]] inline T ReadTemporalGuidCell(RowArrayCursor const& cursor, std::size_t row, SQLUSMALLINT column) + { + using BoundType = RowArrayCursor::BoundType; + auto const boundType = cursor.ColumnBoundType(column); + if constexpr (std::same_as) + return boundType == BoundType::Date ? cursor.GetDate(row, column).value_or(SqlDate {}) : SqlDate {}; + else if constexpr (std::same_as) + return boundType == BoundType::Timestamp ? cursor.GetTimestamp(row, column).value_or(SqlDateTime {}) + : SqlDateTime {}; + else // SqlGuid + { + if (boundType == BoundType::Guid) + return cursor.GetGuid(row, column).value_or(SqlGuid {}); + if (boundType == BoundType::Char || boundType == BoundType::WChar) + return SqlGuid::TryParse(cursor.GetString(row, column).value_or(std::string {})).value_or(SqlGuid {}); + return SqlGuid {}; + } + } + + /// @brief Reconstructs a @c SqlNumeric cell from the block buffer (driver-reported as a fixed-width + /// numeric, bound @c Int64 or @c Double). A non-numeric bound type yields a default. + template + [[nodiscard]] inline T ReadNumericCell(RowArrayCursor const& cursor, std::size_t row, SQLUSMALLINT column) + { + using BoundType = RowArrayCursor::BoundType; + switch (cursor.ColumnBoundType(column)) + { + case BoundType::Double: + return T { cursor.GetF64(row, column).value_or(0.0) }; + case BoundType::Int64: + return T { static_cast(cursor.GetI64(row, column).value_or(0)) }; + default: + return T {}; + } + } + + /// @brief Reconstructs a string-like cell (plain @c std::string flavours and the Lightweight string + /// wrappers) from the block buffer. Character columns bind as @c Char (SQL_C_CHAR) or @c WChar + /// (SQL_C_WCHAR); a non-text bound type (only reachable via a cross-type read of a numeric/temporal + /// column) yields an empty value. Fixed-capacity strings get the same trailing trim the single-row + /// @c GetColumn path applies via @c SqlFixedString::PostProcessOutputColumn, so the value is + /// byte-identical; the UTF-8 bytes are then re-encoded to the target's element type. + template + [[nodiscard]] inline T ReadStringLikeCell(RowArrayCursor const& cursor, std::size_t row, SQLUSMALLINT column) + { + using BoundType = RowArrayCursor::BoundType; + auto const boundType = cursor.ColumnBoundType(column); + if (boundType != BoundType::Char && boundType != BoundType::WChar) + return T {}; + auto utf8 = cursor.GetString(row, column).value_or(std::string {}); + if constexpr (SqlFixedStringCell) + TrimFixedStringBytes(utf8); + return T { DecodeUtf8To(utf8) }; + } + + /// @brief Reconstructs an arithmetic or enum cell from the block buffer, coercing whichever fixed-width + /// representation the column was bound as (@c Int64 or @c Double) to @p T. A non-arithmetic bound type + /// yields a default. + template + [[nodiscard]] inline T ReadArithmeticCell(RowArrayCursor const& cursor, std::size_t row, SQLUSMALLINT column) + { + using BoundType = RowArrayCursor::BoundType; + switch (cursor.ColumnBoundType(column)) + { + case BoundType::Int64: + return static_cast(cursor.GetI64(row, column).value_or(0)); + case BoundType::Double: + return static_cast(cursor.GetF64(row, column).value_or(0.0)); + default: + return T {}; + } + } + } // end namespace detail +template +inline T SqlStatement::ConvertCell(RowArrayCursor const& cursor, std::size_t row, SQLUSMALLINT column) const +{ + // Dispatch the target type to the matching reconstruction helper. The arming allowlist keeps the + // column's bound representation in step with the natural target type; each helper additionally guards + // on the bound type so a cross-type raw GetColumn read degrades to a default rather than throwing. + if constexpr (std::same_as) + return MakePrefetchVariantCell(cursor, row, column); + else if constexpr (IsSpecializationOf) + { + if (cursor.IsCellNull(row, column)) + return std::nullopt; + return T { ConvertCell(cursor, row, column) }; + } + else if constexpr (std::same_as || std::same_as || std::same_as) + return detail::ReadTemporalGuidCell(cursor, row, column); + else if constexpr (detail::SqlNumericCell) + return detail::ReadNumericCell(cursor, row, column); + else if constexpr (detail::StringLikeCell) + return detail::ReadStringLikeCell(cursor, row, column); + else if constexpr (std::is_floating_point_v || std::is_integral_v || std::is_enum_v) + return detail::ReadArithmeticCell(cursor, row, column); + else + // A target type the block buffer cannot reconstruct (e.g. a user type with a custom binder). The + // bound path declines prefetch for such targets (see PrefetchConvertible); reaching here via a raw + // GetColumn returns a default rather than crashing. + return T {}; +} + +template +inline void SqlStatement::RecordPrefetchOutputColumn(SQLUSMALLINT column, T* arg) +{ + auto deferredBind = [this, column, arg] { + RequireIndicators(); + RequireSuccess(SqlDataBinder::OutputColumn(m_hStmt, column, arg, GetIndicatorForColumn(column), *this)); + }; + if constexpr (detail::PrefetchConvertible) + { + RecordPrefetchColumn( + column, + [this, column, arg] { *arg = ConvertCell(PrefetchCursorRef(), PrefetchRowInBlock(), column); }, + std::move(deferredBind)); + } + else + { + // The target type cannot be reconstructed from the block buffer; record only the real bind and + // flag the set so arming declines prefetch and the deferred binds drive the per-row path. + RecordPrefetchColumn(column, {}, std::move(deferredBind)); + MarkPrefetchBindingUnsupported(); + } +} + template inline T SqlStatement::GetColumn(SQLUSMALLINT column) const { + if (IsPrefetchActive()) + { + auto const& cursor = PrefetchCursorRef(); + auto const row = PrefetchRowInBlock(); + RequirePrefetchColumnInRange(cursor, column); + if constexpr (!detail::SqlNullableType) + if (cursor.IsCellNull(row, column)) + throw std::runtime_error { "Column value is NULL" }; + return ConvertCell(cursor, row, column); + } T result {}; SQLLEN indicator {}; { @@ -1547,6 +1920,15 @@ inline T SqlStatement::GetColumn(SQLUSMALLINT column) const template inline std::optional SqlStatement::GetNullableColumn(SQLUSMALLINT column) const { + if (IsPrefetchActive()) + { + auto const& cursor = PrefetchCursorRef(); + auto const row = PrefetchRowInBlock(); + RequirePrefetchColumnInRange(cursor, column); + if (cursor.IsCellNull(row, column)) + return std::nullopt; + return ConvertCell(cursor, row, column); + } T result {}; SQLLEN indicator {}; // TODO: Handle NULL values if we find out that we need them for our use-cases. { @@ -1621,6 +2003,11 @@ inline T SqlStatement::ExecuteDirectScalar(SqlQueryObject auto const& query, std inline LIGHTWEIGHT_FORCE_INLINE void SqlStatement::CloseCursor() noexcept { + // Tear down any block-prefetch first: the RowArrayCursor destructor unbinds the columns and + // restores SQL_ATTR_ROW_ARRAY_SIZE so the SQLFreeStmt(SQL_CLOSE) below — and the next query on this + // statement — start from a clean single-row state. Resets the prefetch lifecycle to Unarmed. + ResetPrefetchState(); + // SQL Server batches and DML/DDL row-count tokens produce multiple result // sets per SQLExecDirect. SQLFreeStmt(SQL_CLOSE) only discards the current // cursor — remaining result sets stay pending on the *connection*, and diff --git a/src/tests/CMakeLists.txt b/src/tests/CMakeLists.txt index 559a9cf38..d41b66765 100644 --- a/src/tests/CMakeLists.txt +++ b/src/tests/CMakeLists.txt @@ -73,6 +73,7 @@ set(SOURCE_FILES SqlRealNameTests.cpp SqlSchemaDbTests.cpp SqlStatementBatchFetchTests.cpp + SqlStatementPrefetchTests.cpp SqlStatementDbTests.cpp SqlTransactionTests.cpp ThreadSafeQueueTests.cpp diff --git a/src/tests/DataBinderTests.cpp b/src/tests/DataBinderTests.cpp index 7cb4a5067..0beeb7b64 100644 --- a/src/tests/DataBinderTests.cpp +++ b/src/tests/DataBinderTests.cpp @@ -1008,6 +1008,10 @@ TEMPLATE_LIST_TEST_CASE("SqlDataBinder specializations", "[SqlDataBinder]", Type } auto stmt = SqlStatement { conn }; + // This suite exercises the per-cell SqlDataBinder round-trip (including custom binders with + // PostProcess hooks that the block-prefetch fast path intentionally defers to the per-row path). + // Disable transparent prefetch so every fetch exercises the single-row binder under test. + stmt.Connection().SetDefaultPrefetchDepth(1); auto const sqlColumnType = [&]() -> std::string { if constexpr (requires { TestTypeTraits::sqlColumnTypeNameOverride; }) diff --git a/src/tests/SqlStatementPrefetchTests.cpp b/src/tests/SqlStatementPrefetchTests.cpp new file mode 100644 index 000000000..49f4fa9a5 --- /dev/null +++ b/src/tests/SqlStatementPrefetchTests.cpp @@ -0,0 +1,535 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include "Utils.hpp" + +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include + +using namespace Lightweight; +using namespace std::string_view_literals; +using namespace std::chrono_literals; + +namespace +{ + +// Counts the transparent block-prefetch signals so a test can prove that N rows collapsed into far +// fewer SQLFetchScroll round-trips (OnFetchBlock) than per-row SQLFetch calls. +struct PrefetchProbe: SqlLogger::Null +{ + std::size_t blockFetches = 0; // SQLFetchScroll round-trips (incl. the terminating empty fetch) + std::size_t logicalRows = 0; // rows handed back to the caller + + void OnFetchBlock(std::size_t /*rowsInBlock*/) override + { + ++blockFetches; + } + void OnFetchRow() override + { + ++logicalRows; + } +}; + +// RAII swap of the active SqlLogger (restores the previous one on scope exit). +struct LoggerSwap +{ + SqlLogger* previous; + explicit LoggerSwap(SqlLogger& replacement): + previous { &SqlLogger::GetLogger() } + { + SqlLogger::SetLogger(replacement); + } + ~LoggerSwap() + { + SqlLogger::SetLogger(*previous); + } + LoggerSwap(LoggerSwap const&) = delete; + LoggerSwap& operator=(LoggerSwap const&) = delete; + LoggerSwap(LoggerSwap&&) = delete; + LoggerSwap& operator=(LoggerSwap&&) = delete; +}; + +constexpr std::size_t TestPrefetchDepth = 64; // small so tests stay fast yet cross block boundaries + +void CreateNumericTable(SqlStatement& stmt) +{ + stmt.MigrateDirect([](SqlMigrationQueryBuilder& migration) { + migration.CreateTable("Prefetch") + .PrimaryKey("Id", SqlColumnTypeDefinitions::Bigint {}) + .Column("Big", SqlColumnTypeDefinitions::Bigint {}) + .Column("Ratio", SqlColumnTypeDefinitions::Real {}) + .Column("Mid", SqlColumnTypeDefinitions::Integer {}); + }); +} + +// Inserts @p rowCount rows of deterministic values into the numeric table. +void FillNumericTable(SqlStatement& stmt, std::size_t rowCount) +{ + stmt.Prepare(stmt.Query("Prefetch") + .Insert() + .Set("Id", SqlWildcard) + .Set("Big", SqlWildcard) + .Set("Ratio", SqlWildcard) + .Set("Mid", SqlWildcard)); + for (auto const i: std::views::iota(std::size_t { 1 }, rowCount + 1)) + { + (void) stmt.Execute(static_cast(i), + static_cast(i) * 1000, + 1.5 * static_cast(i), + static_cast(i % 97)); + } +} + +constexpr auto kNumericSelect = R"(SELECT "Id", "Big", "Ratio", "Mid" FROM "Prefetch" ORDER BY "Id")"sv; + +struct NumericRow +{ + std::int64_t id = 0; + std::int64_t big = 0; + double ratio = 0.0; + int mid = 0; + bool operator==(NumericRow const&) const = default; +}; + +std::vector ReadNumeric(SqlStatement& stmt) +{ + std::vector rows; + auto cursor = stmt.ExecuteDirect(std::string { kNumericSelect }); + while (cursor.FetchRow()) + rows.push_back(NumericRow { + .id = cursor.GetColumn(1), + .big = cursor.GetColumn(2), + .ratio = cursor.GetColumn(3), + .mid = cursor.GetColumn(4), + }); + return rows; +} + +// A statement whose connection has prefetch disabled — the trusted per-row SQLGetData reference. +SqlStatement MakePerRowStatement() +{ + auto stmt = SqlStatement {}; + stmt.Connection().SetDefaultPrefetchDepth(1); + return stmt; +} + +} // namespace + +TEST_CASE_METHOD(SqlTestFixture, "Prefetch: raw GetColumn loop collapses round-trips", "[prefetch]") +{ + constexpr std::size_t rowCount = 2 * TestPrefetchDepth + 7; // crosses two block boundaries + + auto stmt = SqlStatement {}; + stmt.Connection().SetDefaultPrefetchDepth(TestPrefetchDepth); + CreateNumericTable(stmt); + FillNumericTable(stmt, rowCount); + + auto const expected = [&] { + auto reference = MakePerRowStatement(); + return ReadNumeric(reference); + }(); + REQUIRE(expected.size() == rowCount); + + PrefetchProbe probe; + std::vector actual; + { + LoggerSwap const swap { probe }; + actual = ReadNumeric(stmt); + } + + CHECK(actual == expected); + CHECK(probe.logicalRows == rowCount); + // ceil(rowCount / depth) data blocks plus one terminating empty SQLFetchScroll. + auto const expectedDataBlocks = (rowCount + TestPrefetchDepth - 1) / TestPrefetchDepth; + CHECK(probe.blockFetches == expectedDataBlocks + 1); + CHECK(probe.blockFetches < rowCount); // proves batching: far fewer round-trips than rows +} + +TEST_CASE_METHOD(SqlTestFixture, "Prefetch: bound output columns with NULLs and optionals", "[prefetch]") +{ + auto stmt = SqlStatement {}; + stmt.Connection().SetDefaultPrefetchDepth(TestPrefetchDepth); + stmt.MigrateDirect([](SqlMigrationQueryBuilder& migration) { + migration.CreateTable("PrefetchOpt") + .PrimaryKey("Id", SqlColumnTypeDefinitions::Bigint {}) + .Column("MaybeInt", SqlColumnTypeDefinitions::Integer {}); + }); + // MaybeInt nullable: every 5th row is NULL. + stmt.Prepare(stmt.Query("PrefetchOpt").Insert().Set("Id", SqlWildcard).Set("MaybeInt", SqlWildcard)); + constexpr std::size_t rowCount = TestPrefetchDepth + 10; + for (auto const i: std::views::iota(std::size_t { 1 }, rowCount + 1)) + { + auto const maybe = i % 5 == 0 ? std::optional {} : std::optional { static_cast(i) }; + (void) stmt.Execute(static_cast(i), maybe); + } + + PrefetchProbe probe; + std::vector>> actual; + { + LoggerSwap const swap { probe }; + auto cursor = stmt.ExecuteDirect(R"(SELECT "Id", "MaybeInt" FROM "PrefetchOpt" ORDER BY "Id")"sv); + + std::int64_t id = 0; + std::optional maybeInt; + cursor.BindOutputColumns(&id, &maybeInt); + while (cursor.FetchRow()) + { + actual.emplace_back(id, maybeInt); + // The optional rebind idiom from docs/best-practices.md — must stay idempotent under prefetch. + cursor.BindOutputColumns(&id, &maybeInt); + } + } + + REQUIRE(actual.size() == rowCount); + CHECK(probe.blockFetches >= 2); // crossed at least one block boundary + for (auto const i: std::views::iota(std::size_t { 0 }, rowCount)) + { + auto const rowNumber = i + 1; + CHECK(actual[i].first == static_cast(rowNumber)); + if (rowNumber % 5 == 0) + CHECK_FALSE(actual[i].second.has_value()); + else + CHECK(actual[i].second == static_cast(rowNumber)); + } +} + +TEST_CASE_METHOD(SqlTestFixture, "Prefetch: text + temporal result set stays correct on per-row fallback", "[prefetch]") +{ + auto stmt = SqlStatement {}; + stmt.Connection().SetDefaultPrefetchDepth(TestPrefetchDepth); + + stmt.MigrateDirect([](SqlMigrationQueryBuilder& migration) { + migration.CreateTable("PrefetchMixed") + .PrimaryKey("Id", SqlColumnTypeDefinitions::Bigint {}) + .Column("Name", SqlColumnTypeDefinitions::Varchar { 64 }) + .Column("When", SqlColumnTypeDefinitions::DateTime {}); + }); + + constexpr std::size_t rowCount = TestPrefetchDepth + 5; + auto const baseDate = SqlDateTime { 2026y, std::chrono::January, 1d, 12h, 0min, 0s }; + stmt.Prepare( + stmt.Query("PrefetchMixed").Insert().Set("Id", SqlWildcard).Set("Name", SqlWildcard).Set("When", SqlWildcard)); + for (auto const i: std::views::iota(std::size_t { 1 }, rowCount + 1)) + // Mix ASCII and multibyte UTF-8 so the narrow/wide round-trip is exercised on every backend. + (void) stmt.Execute(static_cast(i), std::format("{}-Grüße-{}", "näme", i), baseDate); + + auto readAll = [&](SqlStatement& source) { + std::vector> rows; + auto cursor = source.ExecuteDirect(R"(SELECT "Id", "Name", "When" FROM "PrefetchMixed" ORDER BY "Id")"sv); + while (cursor.FetchRow()) + { + // Read columns into sequenced locals in ascending order: on the per-row path MS SQL's driver + // requires SQLGetData on unbound columns strictly left-to-right, but the evaluation order of + // function arguments is unspecified. (The prefetch path reads from the buffer, order-free.) + auto const id = cursor.GetColumn(1); + auto name = cursor.GetColumn(2); + auto const when = cursor.GetColumn(3); + rows.emplace_back(id, std::move(name), when); + } + return rows; + }; + + auto const expected = [&] { + auto reference = MakePerRowStatement(); + return readAll(reference); + }(); + + PrefetchProbe probe; + std::remove_const_t actual; + { + LoggerSwap const swap { probe }; + actual = readAll(stmt); + } + + CHECK(actual == expected); + REQUIRE(actual.size() == rowCount); + // Character columns are not on the prefetch allowlist (faithful block reconstruction of text is not + // achievable uniformly across backends — see PrefetchableSqlType), so a result set carrying one keeps + // the per-row path. Correctness still holds, including the multibyte UTF-8 values. + CHECK(probe.blockFetches == 0); +} + +TEST_CASE_METHOD(SqlTestFixture, "Prefetch: SqlRowIterator and SqlVariantRowCursor inherit prefetch", "[prefetch]") +{ + constexpr std::size_t rowCount = TestPrefetchDepth + 11; + + auto stmt = SqlStatement {}; + stmt.Connection().SetDefaultPrefetchDepth(TestPrefetchDepth); + CreateNumericTable(stmt); + FillNumericTable(stmt, rowCount); + + SECTION("SqlVariantRowCursor") + { + // Compare prefetch vs per-row variants via their string form (alternative + value must match). + auto toStrings = [](SqlStatement& source) { + std::vector out; + auto cursor = source.ExecuteDirect(std::string { kNumericSelect }); + for (auto const& row: SqlVariantRowCursor { std::move(cursor) }) + { + std::string line; + for (auto const& cell: row) + line += cell.ToString() + "|"; + out.push_back(std::move(line)); + } + return out; + }; + + auto const expected = [&] { + auto reference = MakePerRowStatement(); + return toStrings(reference); + }(); + + PrefetchProbe probe; + std::vector actual; + { + LoggerSwap const swap { probe }; + actual = toStrings(stmt); + } + CHECK(actual == expected); + REQUIRE(actual.size() == rowCount); + CHECK(probe.blockFetches >= 2); + } +} + +TEST_CASE_METHOD(SqlTestFixture, "Prefetch: empty result set", "[prefetch]") +{ + auto stmt = SqlStatement {}; + stmt.Connection().SetDefaultPrefetchDepth(TestPrefetchDepth); + CreateNumericTable(stmt); + + PrefetchProbe probe; + { + LoggerSwap const swap { probe }; + auto cursor = stmt.ExecuteDirect(std::string { kNumericSelect }); + CHECK_FALSE(cursor.FetchRow()); + } + CHECK(probe.logicalRows == 0); +} + +TEST_CASE_METHOD(SqlTestFixture, "Prefetch: SetDefaultPrefetchDepth(1) disables prefetch", "[prefetch]") +{ + constexpr std::size_t rowCount = TestPrefetchDepth + 3; + + auto stmt = SqlStatement {}; + stmt.Connection().SetDefaultPrefetchDepth(1); + CreateNumericTable(stmt); + FillNumericTable(stmt, rowCount); + + PrefetchProbe probe; + std::vector actual; + { + LoggerSwap const swap { probe }; + actual = ReadNumeric(stmt); + } + REQUIRE(actual.size() == rowCount); + CHECK(probe.blockFetches == 0); // per-row SQLFetch path — no block fetches + CHECK(probe.logicalRows == rowCount); +} + +TEST_CASE_METHOD(SqlTestFixture, "Prefetch: ineligible column types fall back to per-row", "[prefetch]") +{ + auto stmt = SqlStatement {}; + stmt.Connection().SetDefaultPrefetchDepth(TestPrefetchDepth); + + // A binary column maps to SQL_*BINARY, which is not on the prefetch allowlist, so the whole result + // set must keep the per-row path while still producing correct values for the other columns. + stmt.MigrateDirect([](SqlMigrationQueryBuilder& migration) { + migration.CreateTable("PrefetchBinary") + .PrimaryKey("Id", SqlColumnTypeDefinitions::Bigint {}) + .Column("Payload", SqlColumnTypeDefinitions::VarBinary { 64 }); + }); + + constexpr std::size_t rowCount = TestPrefetchDepth + 2; + stmt.Prepare(stmt.Query("PrefetchBinary").Insert().Set("Id", SqlWildcard).Set("Payload", SqlWildcard)); + for (auto const i: std::views::iota(std::size_t { 1 }, rowCount + 1)) + (void) stmt.Execute(static_cast(i), SqlBinary { 0x01, 0x02 }); + + PrefetchProbe probe; + std::vector ids; + { + LoggerSwap const swap { probe }; + auto cursor = stmt.ExecuteDirect(R"(SELECT "Id", "Payload" FROM "PrefetchBinary" ORDER BY "Id")"sv); + while (cursor.FetchRow()) + ids.push_back(cursor.GetColumn(1)); + } + REQUIRE(ids.size() == rowCount); + CHECK(probe.blockFetches == 0); // the binary column forced the per-row fallback +} + +TEST_CASE_METHOD(SqlTestFixture, "Prefetch: statement is reusable after a prefetched fetch", "[prefetch]") +{ + constexpr std::size_t rowCount = TestPrefetchDepth + 4; + + auto stmt = SqlStatement {}; + stmt.Connection().SetDefaultPrefetchDepth(TestPrefetchDepth); + CreateNumericTable(stmt); + FillNumericTable(stmt, rowCount); + + auto const first = ReadNumeric(stmt); + REQUIRE(first.size() == rowCount); + + // Re-run on the same statement: the prefetch state must have been restored to single-row defaults. + auto const second = ReadNumeric(stmt); + CHECK(second == first); + + // A different query on the same statement must also work. + auto cursor = stmt.ExecuteDirect(R"(SELECT COUNT(*) FROM "Prefetch")"sv); + REQUIRE(cursor.FetchRow()); + CHECK(cursor.GetColumn(1) == static_cast(rowCount)); +} + +TEST_CASE_METHOD(SqlTestFixture, "Prefetch: string, fixed-string, wide and nullable text round-trip", "[prefetch]") +{ + auto stmt = SqlStatement {}; + stmt.Connection().SetDefaultPrefetchDepth(TestPrefetchDepth); + + stmt.MigrateDirect([](SqlMigrationQueryBuilder& migration) { + migration.CreateTable("PrefetchStr") + .PrimaryKey("Id", SqlColumnTypeDefinitions::Bigint {}) + .Column("Name", SqlColumnTypeDefinitions::Varchar { 32 }) // narrow text (multibyte UTF-8) + .Column("Wide", SqlColumnTypeDefinitions::NVarchar { 32 }) // wide (UTF-16) text + .Column("Tag", SqlColumnTypeDefinitions::Char { 8 }) // CHAR(N): right-padded -> trim parity + .Column("Note", SqlColumnTypeDefinitions::Varchar { 16 }); // nullable text + }); + + constexpr std::size_t rowCount = TestPrefetchDepth + 6; + stmt.Prepare(stmt.Query("PrefetchStr") + .Insert() + .Set("Id", SqlWildcard) + .Set("Name", SqlWildcard) + .Set("Wide", SqlWildcard) + .Set("Tag", SqlWildcard) + .Set("Note", SqlWildcard)); + for (auto const i: std::views::iota(std::size_t { 1 }, rowCount + 1)) + { + auto const name = i % 3 == 0 ? std::format("Grüße-{}", i) : (i % 3 == 1 ? std::string {} : std::format("row-{}", i)); + auto const note = i % 7 == 0 ? std::optional {} : std::optional { std::format("n{}", i) }; + (void) stmt.Execute(static_cast(i), + name, + std::format("wíde-{}", i), + std::format("T{}", i % 100), // short value -> server right-pads to CHAR(8) + note); + } + + // SqlTrimmedFixedString trims the CHAR(N) padding (FIXED_SIZE_RIGHT_TRIMMED); the prefetch path must + // reproduce that trim exactly. std::u16string exercises the wide round-trip; optional + // the NULL path. Comparing against the per-row reference proves byte-identical reconstruction. + using Row = + std::tuple, std::u16string, SqlTrimmedFixedString<8>, std::optional>; + auto readRows = [&](SqlStatement& source) { + std::vector rows; + auto cursor = + source.ExecuteDirect(R"(SELECT "Id", "Name", "Wide", "Tag", "Note" FROM "PrefetchStr" ORDER BY "Id")"sv); + while (cursor.FetchRow()) + { + // Sequenced ascending reads (MS SQL requires per-row SQLGetData in column order). + auto const id = cursor.GetColumn(1); + auto name = cursor.GetColumn>(2); + auto wide = cursor.GetColumn(3); + auto tag = cursor.GetColumn>(4); + auto note = cursor.GetNullableColumn(5); + rows.emplace_back(id, std::move(name), std::move(wide), std::move(tag), std::move(note)); + } + return rows; + }; + + auto const expected = [&] { + auto reference = MakePerRowStatement(); + return readRows(reference); + }(); + + std::vector actual; + { + PrefetchProbe probe; + LoggerSwap const swap { probe }; + actual = readRows(stmt); + } + // Byte-identical to the trusted per-row path on every backend (prefetched where the driver allows, + // per-row where a column — e.g. narrow CHAR on PostgreSQL — opts out; correctness holds regardless). + CHECK(actual == expected); + REQUIRE(actual.size() == rowCount); +} + +TEST_CASE_METHOD(SqlTestFixture, "Prefetch: GUID column round-trips", "[prefetch]") +{ + auto stmt = SqlStatement {}; + stmt.Connection().SetDefaultPrefetchDepth(TestPrefetchDepth); + stmt.MigrateDirect([](SqlMigrationQueryBuilder& migration) { + migration.CreateTable("PrefetchGuid") + .PrimaryKey("Id", SqlColumnTypeDefinitions::Bigint {}) + .Column("Uid", SqlColumnTypeDefinitions::Guid {}); + }); + + constexpr std::size_t rowCount = TestPrefetchDepth + 3; + stmt.Prepare(stmt.Query("PrefetchGuid").Insert().Set("Id", SqlWildcard).Set("Uid", SqlWildcard)); + for (auto const i: std::views::iota(std::size_t { 1 }, rowCount + 1)) + { + auto const uid = SqlGuid::TryParse(std::format("12345678-1234-1234-1234-{:012}", i)).value(); + (void) stmt.Execute(static_cast(i), uid); + } + + auto readGuids = [&](SqlStatement& source) { + std::vector> rows; + auto cursor = source.ExecuteDirect(R"(SELECT "Id", "Uid" FROM "PrefetchGuid" ORDER BY "Id")"sv); + while (cursor.FetchRow()) + { + auto const id = cursor.GetColumn(1); + auto const uid = cursor.GetColumn(2); + rows.emplace_back(id, uid); + } + return rows; + }; + + auto const expected = [&] { + auto reference = MakePerRowStatement(); + return readGuids(reference); + }(); + std::vector> actual; + { + PrefetchProbe probe; + LoggerSwap const swap { probe }; + actual = readGuids(stmt); + } + CHECK(actual == expected); + REQUIRE(actual.size() == rowCount); +} + +// Opt-in micro-benchmark: per-row SQLFetch vs transparent block-prefetch over a large result set. +// Run with: LightweightTest "[prefetchbench]" (hidden by the leading '.'). +TEST_CASE_METHOD(SqlTestFixture, "Prefetch benchmark", "[.][prefetchbench]") +{ + constexpr std::size_t rowCount = 200'000; + + auto stmt = SqlStatement {}; + CreateNumericTable(stmt); + { + auto txn = SqlTransaction { stmt.Connection() }; + FillNumericTable(stmt, rowCount); + txn.Commit(); + } + + auto checksum = [](std::vector const& rows) { + std::int64_t sum = 0; + for (auto const& row: rows) + sum += row.id + row.big + row.mid; + return sum; + }; + + auto perRow = MakePerRowStatement(); + auto const slow = checksum(ReadNumeric(perRow)); + + auto fast = SqlStatement {}; + fast.Connection().SetDefaultPrefetchDepth(PrefetchDepthDefault); + auto const quick = checksum(ReadNumeric(fast)); + + CHECK(slow == quick); +} From 50281ed9ff6e155ca9e91b93ea7ff3149c9d20a4 Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Mon, 22 Jun 2026 18:44:47 +0200 Subject: [PATCH 5/6] [SqlStatement] Fix prefetch CI failures: cross-type string read, clang-tidy, docs, style MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the CI matrix failures from the block-prefetch commit: - Cross-type read regression (the PostgreSQL/Windows dbtool failures): reading a prefetched numeric/temporal/GUID column as a string (GetColumn, as dbtool's generic `exec` printer does) returned an empty string because ConvertCell only rendered character-bound cells. RenderCellAsUtf8 now formats every bound type to text (integers byte-identical to the driver; floating/ temporal/GUID via std::formatter), matching the per-row SQLGetData(SQL_C_CHAR) behaviour. Adds a [prefetch] regression test for the all-numeric-read-as-text case. - clang-tidy (-warnings-as-errors): split is moot — fixed at source. Test file: math-missing-parentheses, integer-sign-comparison, nested conditional operator, std::move on trivially-copyable fixed strings, unchecked optional access. Header: unused-lambda-capture (explicit this-> on the member call). ConvertCell was also split into per-category helpers to stay under the cognitive-complexity threshold. - Doc coverage (doxygen): @ref PrefetchDepthDefault -> @c (it is a value, not a ref target) in SqlConnection.hpp and SqlConnectInfo.hpp; drop the @param naming an unnamed parameter on SqlLogger::OnFetchBlock (described in the brief instead). - C++ style (clang-format-22): restore the single-line empty deleter lambda. Verified: clangcl-debug builds clean; [prefetch] suite green on sqlite3, mssql2022 (Docker), postgres (Docker); dbtool `exec` renders numeric columns. Signed-off-by: Christian Parpart --- src/Lightweight/SqlConnectInfo.hpp | 2 +- src/Lightweight/SqlConnection.hpp | 2 +- src/Lightweight/SqlLogger.hpp | 3 +- src/Lightweight/SqlStatement.cpp | 3 +- src/Lightweight/SqlStatement.hpp | 44 +++++++++++++++----- src/tests/SqlStatementPrefetchTests.cpp | 54 +++++++++++++++++++++---- 6 files changed, 84 insertions(+), 24 deletions(-) diff --git a/src/Lightweight/SqlConnectInfo.hpp b/src/Lightweight/SqlConnectInfo.hpp index 7276e0e73..aa9860db1 100644 --- a/src/Lightweight/SqlConnectInfo.hpp +++ b/src/Lightweight/SqlConnectInfo.hpp @@ -76,7 +76,7 @@ struct [[nodiscard]] SqlConnectionDataSource /// (rows requested per @c SQLFetchScroll round-trip on the transparent per-row fetch path). /// /// A value <= 1 disables prefetch (every classic loop keeps issuing one @c SQLFetch per row). - /// Defaults to @ref PrefetchDepthDefault. Has effect only on backends whose driver supports + /// Defaults to @c PrefetchDepthDefault. Has effect only on backends whose driver supports /// native row-array fetching (see @c SqlConnection::SupportsNativeRowArrayFetch). std::size_t defaultPrefetchDepth = PrefetchDepthDefault; diff --git a/src/Lightweight/SqlConnection.hpp b/src/Lightweight/SqlConnection.hpp index 220fb27d0..d8067ad76 100644 --- a/src/Lightweight/SqlConnection.hpp +++ b/src/Lightweight/SqlConnection.hpp @@ -191,7 +191,7 @@ class SqlConnection final /// instead of issuing one @c SQLFetch per row. A value <= 1 disables prefetch. Effective only when /// @ref SupportsNativeRowArrayFetch is true; otherwise statements fall back to per-row fetching. /// - /// @return The configured default prefetch depth (defaults to @ref PrefetchDepthDefault). + /// @return The configured default prefetch depth (defaults to @c PrefetchDepthDefault). [[nodiscard]] LIGHTWEIGHT_API std::size_t DefaultPrefetchDepth() const noexcept; /// @brief Sets the default block-prefetch depth for statements created on this connection. diff --git a/src/Lightweight/SqlLogger.hpp b/src/Lightweight/SqlLogger.hpp index a12c1f4c6..d9c95677d 100644 --- a/src/Lightweight/SqlLogger.hpp +++ b/src/Lightweight/SqlLogger.hpp @@ -118,8 +118,7 @@ class SqlLogger /// /// Non-pure with an empty default so existing loggers need no change; override to observe how many /// network round-trips a query actually cost (N rows at depth D collapse to @c ceil(N/D) blocks). - /// - /// @param rowsInBlock Number of rows materialized by this block fetch (0 at end of result set). + /// The argument is the number of rows materialized by this block fetch (0 at end of result set). virtual void OnFetchBlock(std::size_t /*rowsInBlock*/) {} /// Invoked when fetching is done. diff --git a/src/Lightweight/SqlStatement.cpp b/src/Lightweight/SqlStatement.cpp index a4aec5fd0..a3e8a79ac 100644 --- a/src/Lightweight/SqlStatement.cpp +++ b/src/Lightweight/SqlStatement.cpp @@ -268,8 +268,7 @@ SqlStatement::SqlStatement(SqlConnection& relatedConnection): } SqlStatement::SqlStatement(std::nullopt_t /*nullopt*/): - m_data { const_cast(&Data::NoData), [](Data* /*data*/) { - } } + m_data { const_cast(&Data::NoData), [](Data* /*data*/) {} } { } diff --git a/src/Lightweight/SqlStatement.hpp b/src/Lightweight/SqlStatement.hpp index e4aaf6b87..607c1d49d 100644 --- a/src/Lightweight/SqlStatement.hpp +++ b/src/Lightweight/SqlStatement.hpp @@ -1068,7 +1068,7 @@ void SqlStatement::BindOutputColumnsToRecord(Records*... records) ((EnumerateRecordMembers(*records, [this, &i](FieldType& value) { ++i; - RecordPrefetchOutputColumn(i, &value); + this->RecordPrefetchOutputColumn(i, &value); })), ...); return; @@ -1798,20 +1798,42 @@ namespace detail } } + /// @brief Renders a block-buffer cell to UTF-8 text. Character columns are returned verbatim; + /// numeric, temporal and GUID columns are formatted to their text form. This mirrors the driver's + /// @c SQL_C_CHAR conversion on the single-row @c GetColumn path so that reading a non-character column + /// as a string (e.g. a generic "print every column as text" loop) yields the value rather than an + /// empty string. Integer text is identical to the driver's; floating/temporal text uses the value + /// type's @c std::formatter, which is backend-independent. + [[nodiscard]] inline std::string RenderCellAsUtf8(RowArrayCursor const& cursor, std::size_t row, SQLUSMALLINT column) + { + switch (cursor.ColumnBoundType(column)) + { + case RowArrayCursor::BoundType::Char: + case RowArrayCursor::BoundType::WChar: + return cursor.GetString(row, column).value_or(std::string {}); + case RowArrayCursor::BoundType::Int64: + return std::format("{}", cursor.GetI64(row, column).value_or(0)); + case RowArrayCursor::BoundType::Double: + return std::format("{}", cursor.GetF64(row, column).value_or(0.0)); + case RowArrayCursor::BoundType::Date: + return std::format("{}", cursor.GetDate(row, column).value_or(SqlDate {})); + case RowArrayCursor::BoundType::Timestamp: + return std::format("{}", cursor.GetTimestamp(row, column).value_or(SqlDateTime {})); + case RowArrayCursor::BoundType::Guid: + return std::format("{}", cursor.GetGuid(row, column).value_or(SqlGuid {})); + } + return std::string {}; + } + /// @brief Reconstructs a string-like cell (plain @c std::string flavours and the Lightweight string - /// wrappers) from the block buffer. Character columns bind as @c Char (SQL_C_CHAR) or @c WChar - /// (SQL_C_WCHAR); a non-text bound type (only reachable via a cross-type read of a numeric/temporal - /// column) yields an empty value. Fixed-capacity strings get the same trailing trim the single-row - /// @c GetColumn path applies via @c SqlFixedString::PostProcessOutputColumn, so the value is - /// byte-identical; the UTF-8 bytes are then re-encoded to the target's element type. + /// wrappers) from the block buffer, rendering any bound type to text via @ref RenderCellAsUtf8. + /// Fixed-capacity strings get the same trailing trim the single-row @c GetColumn path applies via + /// @c SqlFixedString::PostProcessOutputColumn; the UTF-8 bytes are then re-encoded to the target's + /// element type. template [[nodiscard]] inline T ReadStringLikeCell(RowArrayCursor const& cursor, std::size_t row, SQLUSMALLINT column) { - using BoundType = RowArrayCursor::BoundType; - auto const boundType = cursor.ColumnBoundType(column); - if (boundType != BoundType::Char && boundType != BoundType::WChar) - return T {}; - auto utf8 = cursor.GetString(row, column).value_or(std::string {}); + auto utf8 = RenderCellAsUtf8(cursor, row, column); if constexpr (SqlFixedStringCell) TrimFixedStringBytes(utf8); return T { DecodeUtf8To(utf8) }; diff --git a/src/tests/SqlStatementPrefetchTests.cpp b/src/tests/SqlStatementPrefetchTests.cpp index 49f4fa9a5..ddac649e2 100644 --- a/src/tests/SqlStatementPrefetchTests.cpp +++ b/src/tests/SqlStatementPrefetchTests.cpp @@ -126,7 +126,7 @@ SqlStatement MakePerRowStatement() TEST_CASE_METHOD(SqlTestFixture, "Prefetch: raw GetColumn loop collapses round-trips", "[prefetch]") { - constexpr std::size_t rowCount = 2 * TestPrefetchDepth + 7; // crosses two block boundaries + constexpr std::size_t rowCount = (2 * TestPrefetchDepth) + 7; // crosses two block boundaries auto stmt = SqlStatement {}; stmt.Connection().SetDefaultPrefetchDepth(TestPrefetchDepth); @@ -154,6 +154,37 @@ TEST_CASE_METHOD(SqlTestFixture, "Prefetch: raw GetColumn loop collapses round-t CHECK(probe.blockFetches < rowCount); // proves batching: far fewer round-trips than rows } +TEST_CASE_METHOD(SqlTestFixture, "Prefetch: numeric columns read as text render their value (cross-type)", "[prefetch]") +{ + // A generic "print every column as a string" loop (e.g. dbtool's `exec`) reads numeric columns via + // GetColumn. On the per-row path the driver converts the value to text; the prefetch + // path must do the same rather than yield an empty string. Integer text is exact; the double is only + // checked for non-emptiness because its textual form is not portably defined. + auto stmt = SqlStatement {}; + stmt.Connection().SetDefaultPrefetchDepth(TestPrefetchDepth); + CreateNumericTable(stmt); + constexpr std::size_t rowCount = TestPrefetchDepth + 5; + FillNumericTable(stmt, rowCount); + + PrefetchProbe probe; + std::size_t seen = 0; + { + LoggerSwap const swap { probe }; + auto cursor = stmt.ExecuteDirect(std::string { kNumericSelect }); + while (cursor.FetchRow()) + { + auto const rowNumber = static_cast(seen) + 1; + CHECK(cursor.GetColumn(1) == std::format("{}", rowNumber)); // Id (exact) + CHECK(cursor.GetColumn(2) == std::format("{}", rowNumber * 1000)); // Big (exact) + CHECK_FALSE(cursor.GetColumn(3).empty()); // Ratio (double) + CHECK(cursor.GetColumn(4) == std::format("{}", rowNumber % 97)); // Mid (exact) + ++seen; + } + } + REQUIRE(seen == rowCount); + CHECK(probe.blockFetches >= 1); // the all-numeric result set is prefetched +} + TEST_CASE_METHOD(SqlTestFixture, "Prefetch: bound output columns with NULLs and optionals", "[prefetch]") { auto stmt = SqlStatement {}; @@ -193,8 +224,8 @@ TEST_CASE_METHOD(SqlTestFixture, "Prefetch: bound output columns with NULLs and CHECK(probe.blockFetches >= 2); // crossed at least one block boundary for (auto const i: std::views::iota(std::size_t { 0 }, rowCount)) { - auto const rowNumber = i + 1; - CHECK(actual[i].first == static_cast(rowNumber)); + auto const rowNumber = static_cast(i) + 1; + CHECK(actual[i].first == rowNumber); if (rowNumber % 5 == 0) CHECK_FALSE(actual[i].second.has_value()); else @@ -411,7 +442,13 @@ TEST_CASE_METHOD(SqlTestFixture, "Prefetch: string, fixed-string, wide and nulla .Set("Note", SqlWildcard)); for (auto const i: std::views::iota(std::size_t { 1 }, rowCount + 1)) { - auto const name = i % 3 == 0 ? std::format("Grüße-{}", i) : (i % 3 == 1 ? std::string {} : std::format("row-{}", i)); + auto const name = [i] { + if (i % 3 == 0) + return std::format("Grüße-{}", i); + if (i % 3 == 1) + return std::string {}; + return std::format("row-{}", i); + }(); auto const note = i % 7 == 0 ? std::optional {} : std::optional { std::format("n{}", i) }; (void) stmt.Execute(static_cast(i), name, @@ -437,7 +474,9 @@ TEST_CASE_METHOD(SqlTestFixture, "Prefetch: string, fixed-string, wide and nulla auto wide = cursor.GetColumn(3); auto tag = cursor.GetColumn>(4); auto note = cursor.GetNullableColumn(5); - rows.emplace_back(id, std::move(name), std::move(wide), std::move(tag), std::move(note)); + // name (SqlAnsiString<32>) and tag (SqlTrimmedFixedString<8>) are trivially copyable, so they + // are passed by value; only the heap-backed wide string and optional are moved. + rows.emplace_back(id, name, std::move(wide), tag, std::move(note)); } return rows; }; @@ -473,8 +512,9 @@ TEST_CASE_METHOD(SqlTestFixture, "Prefetch: GUID column round-trips", "[prefetch stmt.Prepare(stmt.Query("PrefetchGuid").Insert().Set("Id", SqlWildcard).Set("Uid", SqlWildcard)); for (auto const i: std::views::iota(std::size_t { 1 }, rowCount + 1)) { - auto const uid = SqlGuid::TryParse(std::format("12345678-1234-1234-1234-{:012}", i)).value(); - (void) stmt.Execute(static_cast(i), uid); + auto const parsedUid = SqlGuid::TryParse(std::format("12345678-1234-1234-1234-{:012}", i)); + REQUIRE(parsedUid.has_value()); + (void) stmt.Execute(static_cast(i), *parsedUid); } auto readGuids = [&](SqlStatement& source) { From 4f7c3fa9184604f2f59f5d77ad944d2e2c76725e Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Mon, 22 Jun 2026 19:17:28 +0200 Subject: [PATCH 6/6] [tests] Guard prefetch GUID optional access for clang-tidy The block-prefetch GUID test dereferenced the TryParse result after a Catch2 REQUIRE, which clang-tidy's bugprone-unchecked-optional-access does not track as a guard (-warnings-as-errors). Add an explicit `if (has_value())` so the optional access is statically checked while keeping the REQUIRE as the failure signal. Signed-off-by: Christian Parpart --- src/tests/SqlStatementPrefetchTests.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/tests/SqlStatementPrefetchTests.cpp b/src/tests/SqlStatementPrefetchTests.cpp index ddac649e2..2452bc136 100644 --- a/src/tests/SqlStatementPrefetchTests.cpp +++ b/src/tests/SqlStatementPrefetchTests.cpp @@ -514,7 +514,8 @@ TEST_CASE_METHOD(SqlTestFixture, "Prefetch: GUID column round-trips", "[prefetch { auto const parsedUid = SqlGuid::TryParse(std::format("12345678-1234-1234-1234-{:012}", i)); REQUIRE(parsedUid.has_value()); - (void) stmt.Execute(static_cast(i), *parsedUid); + if (parsedUid.has_value()) // explicit guard so the optional access is statically checked + (void) stmt.Execute(static_cast(i), *parsedUid); } auto readGuids = [&](SqlStatement& source) {