Skip to content

Commit 01db167

Browse files
Add ODBC-native row-wise array fetch for fast bulk retrieval (#511)
Customers on high-latency links wait the longest when retrieving many rows because each row is its own `SQLFetch` round-trip — fetching 1000 rows costs 1000 round-trips. This adds the read-side counterpart of the existing `CreateAll`/`UpdateAll` batch-write path: `Query<Record>().All()`/`Range()` (and two-record JOIN tuples) now bind result columns row-wise directly into the caller's `std::vector<Record>` storage and pull whole row blocks per `SQLFetchScroll`, collapsing the round-trips from N to ~`ceil(N/depth)`. Values land in place (zero-copy); results are byte-identical to the per-row path. The fast path is transparent and gated — a record qualifies when every result column is row-bindable (the same `SqlRowBindableColumn` set the write side uses: 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) transparently falls back to the unchanged per-row path. Verified against SQLite, SQL Server 2022 and PostgreSQL. ## Changes - **`SqlStatement::FetchAllRowWise`** — new low-level primitive mirroring `ExecuteBatchNativeRowWise`: sets `SQL_ATTR_ROW_BIND_TYPE = sizeof(Record)` + `SQL_ATTR_ROW_ARRAY_SIZE`, grows-and-rebinds the destination per block, clamps depth to a memory budget, and restores single-row statement state via a `Finally` guard on every exit (including exceptions). Nullable columns use an over-allocated row-strided NULL indicator; optionals are pre-engaged and reset on NULL. Char fixed strings bind `SQL_C_CHAR` inline with a per-row length/trim fixup. - **`SqlConnection::SupportsNativeRowArrayFetch`** and **`RoundTripsNarrowTextByteExact`** — driver capability checks kept on the connection. The latter carves out PostgreSQL (whose psqlODBC transcodes `SQL_C_CHAR` through the client codepage), so records carrying a fixed-capacity string fall back to the per-row wide path there rather than risk mangling non-ASCII bytes. - **`DataMapper` retrieval wiring** — compile-time eligibility (`CanRowWiseFetchRecord`/`CanRowWiseFetchTuple` plus the narrow-text carve-out) and the `ReadResults` branch for both single-record and two-record tuple result sets. - **`RowWiseFetchTests`** — covers fixed/nullable/temporal/fixed-string columns, 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). A hidden opt-in benchmark (`[.rowwisefetchbench]`) reproduces the comparison below. ## Performance The structural win is round-trip count: for 200k rows the row-wise path issues **~196 `SQLFetchScroll` calls instead of 200,000 `SQLFetch` calls** (~1000x fewer round-trips). Measured end-to-end (release build, median of 5 reps, `Query<>().All()` vs the per-row fallback, both materializing the same `std::vector<Record>`): | Backend | Transport | per-row `SQLFetch` | row-wise block fetch | speedup | |---|---|---|---|---| | SQLite | in-process | 643 ms / 500k | 541 ms | **1.19x** | | PostgreSQL | localhost TCP | 167 ms / 200k | 109 ms | **1.53x** | | SQL Server 2022 | localhost TCP | 134 ms / 200k | 67 ms | **2.01x** | The speedup scales with per-round-trip transport cost. In-process SQLite has no socket, so its ~1.2x is purely reduced per-call CPU/ODBC overhead — the floor, and a latency-independent constant. Over a localhost socket (sub-millisecond RTT) it is already 1.5-2x. On a high-latency link the round-trip term dominates: wall-clock ≈ round-trips × RTT, so the per-row path pays ~N × RTT while the block path pays ~`ceil(N/depth)` × RTT, and the speedup approaches the effective array depth (up to **~1000x** for narrow records, less for wide rows that clamp to a smaller depth, and divided by whatever the driver already prefetches per fetch). For example, modelled at 50 ms RTT for 100k rows: ~83 min (per-row) vs ~5 s (block). The local numbers above are a conservative lower bound demonstrating the mechanism; the round-trip-count reduction is what carries the win to the WAN case.
2 parents 85c180a + 4f7c3fa commit 01db167

13 files changed

Lines changed: 2564 additions & 3 deletions

docs/best-practices.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,25 @@ single response.
106106
This will help to reduce the response time and the load on the server, and improve the performance of your
107107
application.
108108
109+
### Let block-prefetch cut network round-trips
110+
111+
Per-row fetch loops issue one `SQLFetch` (one network round-trip) per row. Lightweight transparently
112+
fetches rows in blocks (ODBC row-array binding) so a large result set costs `ceil(rows / depth)`
113+
round-trips instead of one per row — see [Transparent block-prefetch](usage.md). It is on by default
114+
(`Lightweight::PrefetchDepthDefault`, 1000 rows) and tuned per connection:
115+
116+
```cpp
117+
connection.SetDefaultPrefetchDepth(1000); // rows per SQLFetchScroll round-trip; <= 1 disables
118+
```
119+
120+
Keep in mind:
121+
122+
- It engages only for **fixed-width numeric/temporal** result sets; result sets with character,
123+
`GUID`, `NUMERIC`, `TIME`, binary or LOB columns transparently stay on the per-row path.
124+
- An active cursor reads ahead up to one block and holds a few MB of buffers, so set the depth to `1`
125+
on a connection used for cursors you intend to abandon early or where memory is tight.
126+
- It does not change results — values are identical to the per-row path.
127+
109128
## SQL Server Variation Challenges
110129

111130
### 64-bit Integer Handling in Oracle Database

docs/usage.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,38 @@ while (stmt.FetchRow())
3232
}
3333
```
3434

35+
## Transparent block-prefetch (fewer network round-trips)
36+
37+
Classic per-row fetch loops like the one above issue **one `SQLFetch` per row**, i.e. one network
38+
round-trip per row. On TCP-backed drivers (Microsoft SQL Server, PostgreSQL) that latency dominates the
39+
wall-clock time of large result sets.
40+
41+
Lightweight transparently reduces these round-trips: on the first `FetchRow()` of a result set it
42+
inspects the columns and, when eligible, fetches whole **blocks** of rows per `SQLFetchScroll`
43+
round-trip (ODBC row-array binding) and serves your `FetchRow()` / `GetColumn<T>()` calls from that
44+
buffer. **No code change is required** — the loops above, `SqlRowIterator<T>`, `SqlVariantRowCursor`
45+
and the `DataMapper` all benefit automatically.
46+
47+
The depth is a connection-level setting (default `Lightweight::PrefetchDepthDefault`, 1000 rows). A
48+
value `<= 1` disables prefetch and restores one `SQLFetch` per row:
49+
50+
```cpp
51+
auto conn = SqlConnection {};
52+
conn.SetDefaultPrefetchDepth(2000); // request up to 2000 rows per SQLFetchScroll round-trip
53+
conn.SetDefaultPrefetchDepth(1); // disable prefetch for this connection
54+
```
55+
56+
Prefetch engages only for result sets whose columns are **fixed-width numeric, temporal, or `GUID`**
57+
types (integers, floating point, `DATE`, `TIMESTAMP`/`DATETIME`, and native `GUID`/`uniqueidentifier`/
58+
`uuid`) on drivers that support native row-array fetching (Microsoft SQL Server, PostgreSQL, SQLite).
59+
Result sets that contain character/text, `NUMERIC`/`DECIMAL`, `TIME`, binary or LOB columns transparently
60+
keep the per-row path: faithful block reconstruction of those is not achievable uniformly across backends
61+
(e.g. Microsoft SQL Server returns narrow text in the client codepage rather than UTF-8, and SQLite's
62+
dynamic typing reports text/`NUMERIC` columns with an unreliable, unenforced size), so the dedicated
63+
single-row binders handle them. Memory is bounded to a few MB per active cursor (the depth is auto-clamped
64+
to that budget), and prefetch reads ahead up to one block, so a loop that stops early over-reads at most
65+
one block.
66+
3567
## Prepared Statements
3668

3769
You can also use prepared statements to execute queries, for example

src/Lightweight/DataMapper/DataMapper.hpp

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -744,6 +744,220 @@ namespace detail
744744
BindAllOutputColumnsWithOffset(reader, record, 1);
745745
}
746746

747+
/// @brief Requested rows per SQLFetchScroll round-trip for the native row-wise fetch fast path. The
748+
/// statement clamps this to a memory budget, so it is an upper bound, not a guarantee.
749+
constexpr std::size_t kDefaultRowArrayFetchDepth = 1024;
750+
751+
/// @brief Mutable-reference output accessor for member @p I that is a Field/BelongsTo: yields the
752+
/// field's mutable value so the row-wise fetch path binds the result column in place. The read-side
753+
/// counterpart of @ref FieldValueAccessor.
754+
template <std::size_t I>
755+
struct MutableFieldValueAccessor
756+
{
757+
template <typename Record>
758+
decltype(auto) operator()(Record& record) const
759+
{
760+
return GetRecordMemberAt<I>(record).MutableValue();
761+
}
762+
};
763+
764+
/// @brief The mutable value type bound for member @p FieldType on the row-wise fetch path (the type
765+
/// the result column materializes into).
766+
template <typename FieldType>
767+
using RowWiseColumnValueType = std::remove_cvref_t<decltype(std::declval<FieldType&>().MutableValue())>;
768+
769+
/// @return Whether @p FieldType maps to a result column on the bound-output path (Field, BelongsTo, or
770+
/// a directly-bindable member) — mirrors the classification in @ref BindAllOutputColumnsWithOffset.
771+
template <typename FieldType>
772+
constexpr bool RowWiseIsColumn()
773+
{
774+
return IsField<FieldType> || IsBelongsTo<FieldType> || SqlOutputColumnBinder<FieldType>;
775+
}
776+
777+
/// @return Whether @p FieldType is acceptable on the row-wise fetch path: either it is not a result
778+
/// column (a relation member, which is not bound) or it is a column whose value type is
779+
/// @ref SqlRowWiseFetchableColumn. Directly-bindable non-Field members are conservatively rejected
780+
/// (their value would need a separate accessor shape) so such records fall back to the per-row path.
781+
template <typename FieldType>
782+
constexpr bool RowWiseColumnAcceptable()
783+
{
784+
if constexpr (IsField<FieldType> || IsBelongsTo<FieldType>)
785+
return SqlRowWiseFetchableColumn<RowWiseColumnValueType<FieldType>>;
786+
else if constexpr (SqlOutputColumnBinder<FieldType>)
787+
return false;
788+
else
789+
return true; // relation / non-column member: not bound, imposes no constraint
790+
}
791+
792+
template <typename Record, std::size_t... Is>
793+
constexpr bool CanRowWiseFetchRecordImpl(std::index_sequence<Is...>)
794+
{
795+
// The row-strided indicator slots are addressed at i * sizeof(Record); they must stay SQLLEN
796+
// aligned, so sizeof(Record) must be a multiple of alignof(SQLLEN) (mirrors the write-side
797+
// indicatorAlignmentSatisfied precondition).
798+
return (sizeof(Record) % alignof(SQLLEN) == 0) && (RowWiseColumnAcceptable<RecordMemberTypeOf<Is, Record>>() && ...)
799+
&& (RowWiseIsColumn<RecordMemberTypeOf<Is, Record>>() || ...);
800+
}
801+
802+
/// @brief Whether @p Record can be materialized via the native row-wise array-fetch fast path: every
803+
/// result column is a Field/BelongsTo of a @ref SqlRowWiseFetchableColumn type, there is at least one
804+
/// column, and the record size keeps the row-strided indicators aligned. Records that fail this fall
805+
/// back to the per-row @c SQLFetch path, with identical results.
806+
template <typename Record>
807+
constexpr bool CanRowWiseFetchRecord()
808+
{
809+
return CanRowWiseFetchRecordImpl<Record>(std::make_index_sequence<RecordMemberCount<Record>> {});
810+
}
811+
812+
/// Returns a one-element accessor tuple for member @p I when it is a bound result column, else an empty
813+
/// tuple — flattened via tuple_cat so the accessor pack matches the bound column set and order exactly.
814+
template <std::size_t I, typename Record>
815+
auto MakeOutputColumnAccessor()
816+
{
817+
using FieldType = RecordMemberTypeOf<I, Record>;
818+
if constexpr (IsField<FieldType> || IsBelongsTo<FieldType>)
819+
return std::tuple<MutableFieldValueAccessor<I>> {};
820+
else
821+
return std::tuple<> {};
822+
}
823+
824+
/// @brief Materializes the whole result set into @p records via @ref SqlStatement::FetchAllRowWise,
825+
/// building one mutable value accessor per bound result column (same set and order as
826+
/// @ref BindAllOutputColumnsWithOffset). Precondition: @ref CanRowWiseFetchRecord<Record>().
827+
template <typename Record>
828+
void ReadAllRowWise(SqlResultCursor& reader, std::vector<Record>* records)
829+
{
830+
[&]<std::size_t... Is>(std::index_sequence<Is...>) {
831+
std::apply(
832+
[&](auto const&... accessors) {
833+
reader.FetchAllRowWise(*records, kDefaultRowArrayFetchDepth, accessors...);
834+
},
835+
std::tuple_cat(MakeOutputColumnAccessor<Is, Record>()...));
836+
}(std::make_index_sequence<RecordMemberCount<Record>> {});
837+
}
838+
839+
/// @return Whether @p FieldType is a result column whose value is a char fixed-capacity string (or a
840+
/// @c std::optional of one). Such columns are array-bound narrow (SQL_C_CHAR), which only round-trips
841+
/// byte-exact where @ref SqlConnection::RoundTripsNarrowTextByteExact holds.
842+
template <typename FieldType>
843+
constexpr bool ColumnIsNarrowFixedString()
844+
{
845+
if constexpr (IsField<FieldType> || IsBelongsTo<FieldType>)
846+
{
847+
using V = RowWiseColumnValueType<FieldType>;
848+
if constexpr (SqlIsStdOptional<V>)
849+
return IsSqlFixedString<typename V::value_type>;
850+
else
851+
return IsSqlFixedString<V>;
852+
}
853+
else
854+
return false;
855+
}
856+
857+
template <typename Record, std::size_t... Is>
858+
constexpr bool RecordHasNarrowFixedStringColumnImpl(std::index_sequence<Is...>)
859+
{
860+
return (ColumnIsNarrowFixedString<RecordMemberTypeOf<Is, Record>>() || ...);
861+
}
862+
863+
/// @brief Whether @p Record has any char fixed-capacity-string result column. Such records take the
864+
/// row-wise fetch fast path only on backends that round-trip narrow text byte-exact; elsewhere they
865+
/// fall back to the per-row (wide) path. See @ref SqlConnection::RoundTripsNarrowTextByteExact.
866+
template <typename Record>
867+
constexpr bool RecordHasNarrowFixedStringColumn()
868+
{
869+
return RecordHasNarrowFixedStringColumnImpl<Record>(std::make_index_sequence<RecordMemberCount<Record>> {});
870+
}
871+
872+
/// @brief Whether @p Record may use the row-wise fetch fast path on @p serverType: it is row-wise
873+
/// fetchable, the driver supports row-array fetch, and any narrow fixed-string column round-trips
874+
/// byte-exact there. Single runtime gate composed from connection capabilities + the compile-time
875+
/// record shape, so business logic never branches on the server type directly.
876+
template <typename Record>
877+
bool CanRowWiseFetchOn(SqlServerType serverType)
878+
{
879+
if constexpr (!CanRowWiseFetchRecord<Record>())
880+
return false;
881+
else
882+
return SqlConnection::SupportsNativeRowArrayFetch(serverType)
883+
&& (!RecordHasNarrowFixedStringColumn<Record>()
884+
|| SqlConnection::RoundTripsNarrowTextByteExact(serverType));
885+
}
886+
887+
// --- Two-record tuple (JOIN) fast path ----------------------------------------------------------
888+
889+
/// @brief Mutable-reference output accessor for member @p I of the @p TupleIndex-th sub-record of a
890+
/// @c std::tuple result row; yields that field's mutable value so a JOIN result binds in place.
891+
template <std::size_t TupleIndex, std::size_t I>
892+
struct MutableTupleFieldAccessor
893+
{
894+
template <typename TupleType>
895+
decltype(auto) operator()(TupleType& row) const
896+
{
897+
return GetRecordMemberAt<I>(std::get<TupleIndex>(row)).MutableValue();
898+
}
899+
};
900+
901+
template <typename First, typename Second, std::size_t... Fs, std::size_t... Ss>
902+
constexpr bool CanRowWiseFetchTupleImpl(std::index_sequence<Fs...>, std::index_sequence<Ss...>)
903+
{
904+
return (sizeof(std::tuple<First, Second>) % alignof(SQLLEN) == 0)
905+
&& (RowWiseColumnAcceptable<RecordMemberTypeOf<Fs, First>>() && ...)
906+
&& (RowWiseColumnAcceptable<RecordMemberTypeOf<Ss, Second>>() && ...)
907+
&& ((RowWiseIsColumn<RecordMemberTypeOf<Fs, First>>() || ...)
908+
|| (RowWiseIsColumn<RecordMemberTypeOf<Ss, Second>>() || ...));
909+
}
910+
911+
/// @brief Whether a @c std::tuple<First,Second> JOIN row can be materialized via the row-wise fetch
912+
/// fast path: both sub-records' columns are row-bindable and the combined row size keeps the
913+
/// row-strided indicators aligned.
914+
template <typename First, typename Second>
915+
constexpr bool CanRowWiseFetchTuple()
916+
{
917+
return CanRowWiseFetchTupleImpl<First, Second>(std::make_index_sequence<RecordMemberCount<First>> {},
918+
std::make_index_sequence<RecordMemberCount<Second>> {});
919+
}
920+
921+
/// @brief Whether a @c std::tuple<First,Second> JOIN row may use the row-wise fetch fast path on
922+
/// @p serverType (row-wise fetchable + driver supports row-array fetch + any narrow fixed-string
923+
/// column round-trips byte-exact there). The tuple counterpart of @ref CanRowWiseFetchOn.
924+
template <typename First, typename Second>
925+
bool CanRowWiseFetchTupleOn(SqlServerType serverType)
926+
{
927+
if constexpr (!CanRowWiseFetchTuple<First, Second>())
928+
return false;
929+
else
930+
return SqlConnection::SupportsNativeRowArrayFetch(serverType)
931+
&& ((!RecordHasNarrowFixedStringColumn<First>() && !RecordHasNarrowFixedStringColumn<Second>())
932+
|| SqlConnection::RoundTripsNarrowTextByteExact(serverType));
933+
}
934+
935+
/// Accessor tuple for member @p I of the @p TupleIndex-th sub-record, or empty for non-columns.
936+
template <std::size_t TupleIndex, std::size_t I, typename SubRecord>
937+
auto MakeTupleColumnAccessor()
938+
{
939+
using FieldType = RecordMemberTypeOf<I, SubRecord>;
940+
if constexpr (IsField<FieldType> || IsBelongsTo<FieldType>)
941+
return std::tuple<MutableTupleFieldAccessor<TupleIndex, I>> {};
942+
else
943+
return std::tuple<> {};
944+
}
945+
946+
/// @brief Materializes a two-record JOIN result set into @p records via row-wise array fetch. The
947+
/// accessor pack is First's columns followed by Second's, matching the column order of
948+
/// @ref BindAllOutputColumnsWithOffset's offset scheme. Precondition: @ref CanRowWiseFetchTuple.
949+
template <typename First, typename Second>
950+
void ReadAllRowWiseTuple(SqlResultCursor& reader, std::vector<std::tuple<First, Second>>* records)
951+
{
952+
[&]<std::size_t... Fs, std::size_t... Ss>(std::index_sequence<Fs...>, std::index_sequence<Ss...>) {
953+
std::apply(
954+
[&](auto const&... accessors) {
955+
reader.FetchAllRowWise(*records, kDefaultRowArrayFetchDepth, accessors...);
956+
},
957+
std::tuple_cat(MakeTupleColumnAccessor<0, Fs, First>()..., MakeTupleColumnAccessor<1, Ss, Second>()...));
958+
}(std::make_index_sequence<RecordMemberCount<First>> {}, std::make_index_sequence<RecordMemberCount<Second>> {});
959+
}
960+
747961
// when we iterate over all columns using element mask
748962
// indexes of the mask corresponds to the indexe of the field
749963
// inside the structure, not inside the SQL result set
@@ -1268,6 +1482,19 @@ void SqlAllFieldsQueryBuilder<Record, QueryOptions, Execution>::ReadResults(SqlS
12681482
SqlResultCursor reader,
12691483
std::vector<Record>* records)
12701484
{
1485+
// Fast path: when every result column is a fixed-width row-bindable field and the driver honours
1486+
// native row-array fetching, materialize the whole result set in row blocks (one SQLFetchScroll per
1487+
// block) directly into records, instead of one SQLFetch round-trip per row. Results are identical to
1488+
// the per-row path below; this only collapses ODBC round-trips (the win on high-latency links).
1489+
if constexpr (detail::CanRowWiseFetchRecord<Record>())
1490+
{
1491+
if (detail::CanRowWiseFetchOn<Record>(sqlServerType))
1492+
{
1493+
detail::ReadAllRowWise(reader, records);
1494+
return;
1495+
}
1496+
}
1497+
12711498
while (true)
12721499
{
12731500
Record& record = records->emplace_back();
@@ -1293,6 +1520,17 @@ template <typename FirstRecord, typename SecondRecord, DataMapperOptions QueryOp
12931520
void SqlAllFieldsQueryBuilder<std::tuple<FirstRecord, SecondRecord>, QueryOptions, Execution>::ReadResults(
12941521
SqlServerType sqlServerType, SqlResultCursor reader, std::vector<RecordType>* records)
12951522
{
1523+
// Fast path: a JOIN row of two row-bindable records is bound row-wise over the tuple and fetched in
1524+
// blocks (one SQLFetchScroll per block) instead of one SQLFetch per row. Identical results.
1525+
if constexpr (detail::CanRowWiseFetchTuple<FirstRecord, SecondRecord>())
1526+
{
1527+
if (detail::CanRowWiseFetchTupleOn<FirstRecord, SecondRecord>(sqlServerType))
1528+
{
1529+
detail::ReadAllRowWiseTuple<FirstRecord, SecondRecord>(reader, records);
1530+
return;
1531+
}
1532+
}
1533+
12961534
while (true)
12971535
{
12981536
auto& record = records->emplace_back();

src/Lightweight/SqlConnectInfo.hpp

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
#include "Api.hpp"
66

77
#include <chrono>
8+
#include <cstddef>
89
#include <format>
910
#include <map>
1011
#include <string>
@@ -13,6 +14,15 @@
1314
namespace Lightweight
1415
{
1516

17+
/// @brief Default block-prefetch depth for new connections: the number of rows a classic per-row
18+
/// fetch loop requests per @c SQLFetchScroll round-trip on the transparent prefetch path.
19+
///
20+
/// Suffixed (not @c DefaultPrefetchDepth) so it does not collide with the
21+
/// @c SqlConnection::DefaultPrefetchDepth() accessor. A connection's depth can be overridden via
22+
/// @c SqlConnection::SetDefaultPrefetchDepth or @ref SqlConnectionDataSource::defaultPrefetchDepth;
23+
/// a value <= 1 disables prefetch.
24+
constexpr std::size_t PrefetchDepthDefault = 1000;
25+
1626
/// Represents an ODBC connection string.
1727
struct SqlConnectionString
1828
{
@@ -62,6 +72,13 @@ struct [[nodiscard]] SqlConnectionDataSource
6272
std::string password;
6373
/// The connection timeout duration.
6474
std::chrono::seconds timeout { 5 };
75+
/// @brief Default block-prefetch depth applied to statements created on the resulting connection
76+
/// (rows requested per @c SQLFetchScroll round-trip on the transparent per-row fetch path).
77+
///
78+
/// A value <= 1 disables prefetch (every classic loop keeps issuing one @c SQLFetch per row).
79+
/// Defaults to @c PrefetchDepthDefault. Has effect only on backends whose driver supports
80+
/// native row-array fetching (see @c SqlConnection::SupportsNativeRowArrayFetch).
81+
std::size_t defaultPrefetchDepth = PrefetchDepthDefault;
6582

6683
/// Constructs a SqlConnectionDataSource from the given connection string.
6784
LIGHTWEIGHT_API static SqlConnectionDataSource FromConnectionString(SqlConnectionString const& value);

0 commit comments

Comments
 (0)