@@ -122,6 +122,40 @@ SqlStatement MakePerRowStatement()
122122 return stmt;
123123}
124124
125+ // Strips the server's CHAR(N)/NCHAR(N) right-padding (trailing ASCII spaces) so a fixed-width
126+ // column's value compares equal regardless of how much padding the driver returned it with.
127+ std::optional<std::string> TrimRight (std::optional<std::string> value)
128+ {
129+ if (!value.has_value ())
130+ return std::nullopt ;
131+ while (!value->empty () && value->back () == ' ' )
132+ value->pop_back ();
133+ return value;
134+ }
135+
136+ // Unwraps a RowArrayCursor cell known to be non-NULL (e.g. the primary key column, or a text
137+ // column the test never inserts NULL into), REQUIRE-ing that first. The explicit if-with-throw is
138+ // what clang-tidy's bugprone-unchecked-optional-access analysis recognizes as a null check —
139+ // REQUIRE alone is a macro it cannot reason about (see the same pattern in SqlGuidTests.cpp's
140+ // RequireParsed).
141+ std::int64_t RequireI64 (RowArrayCursor const & cursor, std::size_t row, SQLUSMALLINT column)
142+ {
143+ auto const value = cursor.GetI64 (row, column);
144+ REQUIRE (value.has_value ());
145+ if (!value.has_value ())
146+ throw std::runtime_error (" REQUIRE failed but flow continued" ); // unreachable
147+ return *value;
148+ }
149+
150+ std::string RequireString (RowArrayCursor const & cursor, std::size_t row, SQLUSMALLINT column)
151+ {
152+ auto value = cursor.GetString (row, column);
153+ REQUIRE (value.has_value ());
154+ if (!value.has_value ())
155+ throw std::runtime_error (" REQUIRE failed but flow continued" ); // unreachable
156+ return std::move (*value);
157+ }
158+
125159} // namespace
126160
127161TEST_CASE_METHOD (SqlTestFixture, " Prefetch: raw GetColumn loop collapses round-trips" , " [prefetch]" )
@@ -544,6 +578,272 @@ TEST_CASE_METHOD(SqlTestFixture, "Prefetch: GUID column round-trips", "[prefetch
544578 REQUIRE (actual.size () == rowCount);
545579}
546580
581+ TEST_CASE_METHOD (SqlTestFixture, " Prefetch: RowArrayCursor bulk-reads fixed-width CHAR/NCHAR columns" , " [prefetch]" )
582+ {
583+ // Fixed-width text (CHAR(N)/NCHAR(N)) is excluded from the transparent automatic prefetch gate
584+ // (PrefetchableSqlType in SqlStatement.cpp) and always falls back to per-row SQLGetData there.
585+ // RowArrayCursor — the actual bulk block-fetch mechanism reachable via ExecuteBatchFetch — binds
586+ // and reads fixed-width text directly, independent of that gate. This pins that a bulk fetch of
587+ // CHAR(N)/NCHAR(N) columns, including NULLs and the server's right-padding, is byte-identical to
588+ // the trusted per-row reference.
589+ auto stmt = SqlStatement {};
590+
591+ stmt.MigrateDirect ([](SqlMigrationQueryBuilder& migration) {
592+ migration.CreateTable (" PrefetchFixedWidth" )
593+ .PrimaryKey (" Id" , SqlColumnTypeDefinitions::Bigint {})
594+ .Column (" Code" , SqlColumnTypeDefinitions::Char { 8 }) // narrow fixed-width, right-padded
595+ .Column (" WideCode" , SqlColumnTypeDefinitions::NChar { 8 }); // wide fixed-width, right-padded
596+ });
597+
598+ constexpr std::size_t rowCount = TestPrefetchDepth + 6 ;
599+ stmt.Prepare (stmt.Query (" PrefetchFixedWidth" )
600+ .Insert ()
601+ .Set (" Id" , SqlWildcard)
602+ .Set (" Code" , SqlWildcard)
603+ .Set (" WideCode" , SqlWildcard));
604+ for (auto const i: std::views::iota (std::size_t { 1 }, rowCount + 1 ))
605+ {
606+ // Every 4th row is NULL in both fixed-width columns; the rest carry a short value shorter
607+ // than the column's declared width, so the server's right-padding is actually exercised.
608+ if (i % 4 == 0 )
609+ (void ) stmt.Execute (static_cast <std::int64_t >(i), std::optional<std::string> {}, std::optional<std::string> {});
610+ else
611+ (void ) stmt.Execute (static_cast <std::int64_t >(i),
612+ std::optional<std::string> { std::format (" C{}" , i % 100 ) },
613+ std::optional<std::string> { std::format (" W{}" , i % 100 ) });
614+ }
615+
616+ // Trusted reference: the classic per-row SQLGetData path. SqlTrimmedFixedString strips the
617+ // server's CHAR(N) right-padding on the narrow column; the wide column is read as plain
618+ // std::string (already-proven UTF-16 -> UTF-8 conversion) and trimmed manually here to mirror
619+ // RowArrayCursor::GetString's own trailing-space trim below, since SqlTrimmedWideFixedString's
620+ // GetColumn does not currently apply its trim (a separate, pre-existing gap outside this test's
621+ // scope — see SqlDataBinder<Utf16StringType>::GetColumn in BasicStringBinder.hpp).
622+ using Row = std::tuple<std::int64_t , std::optional<std::string>, std::optional<std::string>>;
623+ auto readPerRow = [&]() {
624+ std::vector<Row> rows;
625+ auto reference = MakePerRowStatement ();
626+ auto cursor =
627+ reference.ExecuteDirect (R"( SELECT "Id", "Code", "WideCode" FROM "PrefetchFixedWidth" ORDER BY "Id")" sv);
628+ while (cursor.FetchRow ())
629+ {
630+ auto const id = cursor.GetColumn <std::int64_t >(1 );
631+ auto const code = cursor.GetNullableColumn <SqlTrimmedFixedString<8 >>(2 );
632+ auto const wideCode = TrimRight (cursor.GetNullableColumn <std::string>(3 ));
633+ rows.emplace_back (
634+ id, code.has_value () ? std::optional<std::string> { code->ToStringView () } : std::nullopt , wideCode);
635+ }
636+ return rows;
637+ };
638+ auto const expected = readPerRow ();
639+ REQUIRE (expected.size () == rowCount);
640+
641+ // RowArrayCursor directly — the bulk block-fetch mechanism under test.
642+ std::vector<Row> actual;
643+ {
644+ auto cursor = stmt.ExecuteBatchFetch (R"( SELECT "Id", "Code", "WideCode" FROM "PrefetchFixedWidth" ORDER BY "Id")" sv,
645+ TestPrefetchDepth);
646+
647+ // Confirm the columns actually bound as text (Char/WChar), not silently as something else.
648+ // Which of Char/WChar a driver picks for a given declared SQL type is driver-specific and does
649+ // not affect correctness (RowArrayCursor::GetString normalizes both to UTF-8) — e.g. SQLite3's
650+ // ODBC driver reports CHAR(N) as WChar on Windows but as Char on Linux, and NCHAR(N) the other
651+ // way around on Linux vs Windows. Accept either for both columns; only the byte-identity check
652+ // below actually matters.
653+ auto const isTextlike = [](RowArrayCursor::BoundType type) {
654+ return type == RowArrayCursor::BoundType::Char || type == RowArrayCursor::BoundType::WChar;
655+ };
656+ CHECK (isTextlike (cursor.ColumnBoundType (2 )));
657+ CHECK (isTextlike (cursor.ColumnBoundType (3 )));
658+
659+ std::size_t blocks = 0 ;
660+ for (auto rowsInBlock = cursor.FetchArray (); rowsInBlock > 0 ; rowsInBlock = cursor.FetchArray ())
661+ {
662+ ++blocks;
663+ for (auto const row: std::views::iota (std::size_t { 0 }, rowsInBlock))
664+ {
665+ auto const id = RequireI64 (cursor, row, 1 );
666+ // Trim the CHAR(8)/NCHAR(8) driver padding to compare against the trimmed reference.
667+ auto code = TrimRight (cursor.GetString (row, 2 ));
668+ auto wideCode = TrimRight (cursor.GetString (row, 3 ));
669+ actual.emplace_back (id, std::move (code), std::move (wideCode));
670+ }
671+ }
672+ CHECK (blocks >= 2 ); // crossed at least one block boundary — genuinely block-fetched, not one row
673+ }
674+
675+ REQUIRE (actual.size () == rowCount);
676+ CHECK (actual == expected); // byte-identical to the trusted per-row path, including NULLs and padding
677+ }
678+
679+ TEST_CASE_METHOD (SqlTestFixture,
680+ " Prefetch: RowArrayCursor bulk-reads a fixed-width value that fills its full capacity" ,
681+ " [prefetch]" )
682+ {
683+ // Regression sibling to #485 (BasicStringBinder::OutputColumn off-by-one on BufferLength) for the
684+ // RowArrayCursor bulk path: a value with NO trailing padding — it fills CHAR(N)/NCHAR(N) exactly —
685+ // must not lose its last character to a buffer sized for the terminator alone.
686+ auto stmt = SqlStatement {};
687+
688+ stmt.MigrateDirect ([](SqlMigrationQueryBuilder& migration) {
689+ migration.CreateTable (" PrefetchFixedFull" )
690+ .PrimaryKey (" Id" , SqlColumnTypeDefinitions::Bigint {})
691+ .Column (" Code" , SqlColumnTypeDefinitions::Char { 8 })
692+ .Column (" WideCode" , SqlColumnTypeDefinitions::NChar { 8 });
693+ });
694+
695+ constexpr std::size_t rowCount = TestPrefetchDepth + 6 ;
696+ stmt.Prepare (stmt.Query (" PrefetchFixedFull" )
697+ .Insert ()
698+ .Set (" Id" , SqlWildcard)
699+ .Set (" Code" , SqlWildcard)
700+ .Set (" WideCode" , SqlWildcard));
701+ for (auto const i: std::views::iota (std::size_t { 1 }, rowCount + 1 ))
702+ {
703+ // Every value is exactly 8 chars — fills CHAR(8)/NCHAR(8) with no padding at all.
704+ auto const code = std::format (" {:08}" , i % 100'000'000 );
705+ (void ) stmt.Execute (static_cast <std::int64_t >(i), code, code);
706+ }
707+
708+ auto readPerRow = [&]() {
709+ std::vector<std::tuple<std::int64_t , std::string, std::string>> rows;
710+ auto reference = MakePerRowStatement ();
711+ auto cursor = reference.ExecuteDirect (R"( SELECT "Id", "Code", "WideCode" FROM "PrefetchFixedFull" ORDER BY "Id")" sv);
712+ while (cursor.FetchRow ())
713+ {
714+ auto const id = cursor.GetColumn <std::int64_t >(1 );
715+ auto const code = cursor.GetColumn <SqlTrimmedFixedString<8 >>(2 );
716+ auto const wideCode = *TrimRight (cursor.GetColumn <std::string>(3 ));
717+ rows.emplace_back (id, std::string { code.ToStringView () }, wideCode);
718+ }
719+ return rows;
720+ };
721+ auto const expected = readPerRow ();
722+ REQUIRE (expected.size () == rowCount);
723+
724+ std::vector<std::tuple<std::int64_t , std::string, std::string>> actual;
725+ {
726+ auto cursor = stmt.ExecuteBatchFetch (R"( SELECT "Id", "Code", "WideCode" FROM "PrefetchFixedFull" ORDER BY "Id")" sv,
727+ TestPrefetchDepth);
728+ for (auto rowsInBlock = cursor.FetchArray (); rowsInBlock > 0 ; rowsInBlock = cursor.FetchArray ())
729+ for (auto const row: std::views::iota (std::size_t { 0 }, rowsInBlock))
730+ actual.emplace_back (
731+ RequireI64 (cursor, row, 1 ), RequireString (cursor, row, 2 ), RequireString (cursor, row, 3 ));
732+ }
733+
734+ REQUIRE (actual.size () == rowCount);
735+ for (std::size_t i = 0 ; i < rowCount; ++i)
736+ {
737+ CHECK (std::get<0 >(actual[i]) == std::get<0 >(expected[i]));
738+ CHECK (std::get<1 >(actual[i]) == std::get<1 >(expected[i])); // full 8 chars, not truncated to 7
739+ CHECK (std::get<2 >(actual[i]) == std::get<2 >(expected[i]));
740+ CHECK (std::get<1 >(actual[i]).size () == 8 );
741+ CHECK (std::get<2 >(actual[i]).size () == 8 );
742+ }
743+ }
744+
745+ TEST_CASE_METHOD (SqlTestFixture,
746+ " Prefetch: RowArrayCursor distinguishes NULL from empty string in fixed-width columns" ,
747+ " [prefetch]" )
748+ {
749+ // NULL and "" are distinct SQL values; a bulk fetch must not collapse one into the other (e.g. by
750+ // treating an all-blank buffer as NULL, or a NULL indicator as an empty-but-non-null string).
751+ auto stmt = SqlStatement {};
752+
753+ stmt.MigrateDirect ([](SqlMigrationQueryBuilder& migration) {
754+ migration.CreateTable (" PrefetchFixedEmptyVsNull" )
755+ .PrimaryKey (" Id" , SqlColumnTypeDefinitions::Bigint {})
756+ .Column (" Code" , SqlColumnTypeDefinitions::Char { 8 })
757+ .Column (" WideCode" , SqlColumnTypeDefinitions::NChar { 8 });
758+ });
759+
760+ constexpr std::size_t rowCount = TestPrefetchDepth + 6 ;
761+ stmt.Prepare (stmt.Query (" PrefetchFixedEmptyVsNull" )
762+ .Insert ()
763+ .Set (" Id" , SqlWildcard)
764+ .Set (" Code" , SqlWildcard)
765+ .Set (" WideCode" , SqlWildcard));
766+ for (auto const i: std::views::iota (std::size_t { 1 }, rowCount + 1 ))
767+ {
768+ // Every 3rd row is NULL, every other 3rd row is an empty string, the rest carry a short value.
769+ if (i % 3 == 0 )
770+ (void ) stmt.Execute (static_cast <std::int64_t >(i), std::optional<std::string> {}, std::optional<std::string> {});
771+ else if (i % 3 == 1 )
772+ (void ) stmt.Execute (static_cast <std::int64_t >(i), std::string {}, std::string {});
773+ else
774+ (void ) stmt.Execute (static_cast <std::int64_t >(i), std::format (" C{}" , i), std::format (" W{}" , i));
775+ }
776+
777+ auto cursor = stmt.ExecuteBatchFetch (
778+ R"( SELECT "Id", "Code", "WideCode" FROM "PrefetchFixedEmptyVsNull" ORDER BY "Id")" sv, TestPrefetchDepth);
779+
780+ std::size_t rowsSeen = 0 ;
781+ for (auto rowsInBlock = cursor.FetchArray (); rowsInBlock > 0 ; rowsInBlock = cursor.FetchArray ())
782+ {
783+ for (auto const row: std::views::iota (std::size_t { 0 }, rowsInBlock))
784+ {
785+ auto const id = RequireI64 (cursor, row, 1 );
786+ ++rowsSeen;
787+
788+ if (id % 3 == 0 )
789+ {
790+ // NULL: the indicator must say so, and GetString must not fabricate a value.
791+ CHECK (cursor.IsCellNull (row, 2 ));
792+ CHECK (cursor.IsCellNull (row, 3 ));
793+ CHECK_FALSE (cursor.GetString (row, 2 ).has_value ());
794+ CHECK_FALSE (cursor.GetString (row, 3 ).has_value ());
795+ }
796+ else if (id % 3 == 1 )
797+ {
798+ // Empty string, server-padded to CHAR(8)/NCHAR(8): NOT null, and trims down to "".
799+ CHECK_FALSE (cursor.IsCellNull (row, 2 ));
800+ CHECK_FALSE (cursor.IsCellNull (row, 3 ));
801+ CHECK (TrimRight (cursor.GetString (row, 2 )) == std::optional<std::string> { " " });
802+ CHECK (TrimRight (cursor.GetString (row, 3 )) == std::optional<std::string> { " " });
803+ }
804+ else
805+ {
806+ CHECK (TrimRight (cursor.GetString (row, 2 )) == std::optional<std::string> { std::format (" C{}" , id) });
807+ CHECK (TrimRight (cursor.GetString (row, 3 )) == std::optional<std::string> { std::format (" W{}" , id) });
808+ }
809+ }
810+ }
811+ CHECK (rowsSeen == rowCount);
812+ }
813+
814+ TEST_CASE_METHOD (SqlTestFixture,
815+ " Prefetch: RowArrayCursor bulk-reads fixed-width columns within a single block" ,
816+ " [prefetch]" )
817+ {
818+ // The other fixed-width tests deliberately cross a block boundary; this pins the same correctness
819+ // for a result set that fits in a single FetchArray() call (arrayDepth > rowCount), so the
820+ // single-block code paths (no continuation, no second SQLGetData) are exercised too.
821+ auto stmt = SqlStatement {};
822+
823+ stmt.MigrateDirect ([](SqlMigrationQueryBuilder& migration) {
824+ migration.CreateTable (" PrefetchFixedSingleBlock" )
825+ .PrimaryKey (" Id" , SqlColumnTypeDefinitions::Bigint {})
826+ .Column (" Code" , SqlColumnTypeDefinitions::Char { 8 });
827+ });
828+
829+ constexpr std::size_t rowCount = 5 ; // well under TestPrefetchDepth
830+ stmt.Prepare (stmt.Query (" PrefetchFixedSingleBlock" ).Insert ().Set (" Id" , SqlWildcard).Set (" Code" , SqlWildcard));
831+ for (auto const i: std::views::iota (std::size_t { 1 }, rowCount + 1 ))
832+ (void ) stmt.Execute (static_cast <std::int64_t >(i), std::format (" C{}" , i));
833+
834+ auto cursor =
835+ stmt.ExecuteBatchFetch (R"( SELECT "Id", "Code" FROM "PrefetchFixedSingleBlock" ORDER BY "Id")" sv, TestPrefetchDepth);
836+
837+ auto const rowsInFirstBlock = cursor.FetchArray ();
838+ REQUIRE (rowsInFirstBlock == rowCount); // the whole result set fit in one block
839+ for (auto const row: std::views::iota (std::size_t { 0 }, rowsInFirstBlock))
840+ {
841+ auto const id = RequireI64 (cursor, row, 1 );
842+ CHECK (TrimRight (cursor.GetString (row, 2 )) == std::optional<std::string> { std::format (" C{}" , id) });
843+ }
844+ CHECK (cursor.FetchArray () == 0 ); // no more rows
845+ }
846+
547847// Opt-in micro-benchmark: per-row SQLFetch vs transparent block-prefetch over a large result set.
548848// Run with: LightweightTest "[prefetchbench]" (hidden by the leading '.').
549849TEST_CASE_METHOD (SqlTestFixture, " Prefetch benchmark" , " [.][prefetchbench]" )
0 commit comments