Summary
SqlBasicStringOperations<SqlFixedString<N>>::OutputColumn and ::GetColumn pass BufferLength = N to SQLBindCol / SQLGetData. Per the ODBC spec, BufferLength for SQL_C_CHAR must include space for the null terminator, so the driver truncates any value of length N to N − 1 data chars + null. Values shorter than N (with trailing whitespace padding from a CHAR(N) column) are unaffected because there is slack space.
Code
src/Lightweight/DataBinder/BasicStringBinder.hpp, OutputColumn:
if constexpr (requires { AnsiStringType::Capacity; })
StringTraits::Resize(result, AnsiStringType::Capacity); // _size = N
...
return SQLBindCol(stmt, column, SQL_C_CHAR,
(SQLPOINTER) StringTraits::Data(result),
(SQLLEN) StringTraits::Size(result), // <-- BufferLength = N
indicator);
SqlFixedString<N> actually allocates its buffer as T _data[N + 1] (so the null-terminator slot exists), but the binder hands ODBC only N bytes.
The same pattern exists in the GetColumn overload (~line 291): SQLGetData(..., AnsiStringType::Capacity, ...) passes N instead of N + 1.
Reproducer
Pseudocode in Lightweight test style:
TEST_CASE_METHOD(SqlTestFixture,
"SqlTrimmedFixedString<3> round-trips a CHAR(3) value that fills the buffer",
"[SqlFixedString][regression]")
{
auto stmt = SqlStatement{};
stmt.ExecuteDirect("CREATE TABLE Currencies (NR INT, CODE CHAR(3))");
stmt.ExecuteDirect("INSERT INTO Currencies VALUES (1, 'EUR')");
SqlTrimmedFixedString<3> code;
SQLLEN indicator = 0;
// Bind code as output column 1, fetch the row, run post-processing.
// Expected: code == "EUR", code.size() == 3
// Actual: code == "EU", code.size() == 2
REQUIRE(code.str() == "EUR");
REQUIRE(code.size() == 3);
}
The existing SqlFixedString: TrimRight test in tests/DataBinderTests.cpp does not exercise this case — both calls pass indicator < N (e.g. SqlTrimmedFixedString<20>{ "Hello " }, TrimRight(&str, 5)), so they have plenty of slack and don't reach the min(indicator, N-1) boundary.
Downstream consequence in TrimRight
SqlFixedString.hpp (around line 474):
size_t n = (std::min) (static_cast<size_t>(indicator) / sizeof(CharType), N - 1);
The N - 1 cap here is consistent with the truncated buffer (since SQLBindCol could only have written N − 1 data chars). It is therefore not separately observable today. Once BufferLength is corrected to N + 1, this cap also needs to become N — otherwise _size would still be capped at N − 1 even though the buffer now has the full value.
Suggested fix
In OutputColumn and GetColumn (and BatchOutputColumn / Reserve / Resize paths if symmetric), pass Capacity + 1 instead of Size(result) / Capacity for types with a compile-time Capacity. Change the N - 1 cap in TrimRight to N. Add a regression test where the stored value has indicator == N and the last byte is non-whitespace.
Why this hasn't been observed before
The bug fires only when:
- The stored value is exactly N chars, and
- The last char is not whitespace (or null)
For most consumers of SqlTrimmedFixedString<N> with N ≥ 10, real values rarely fill the buffer with no padding. We tripped over it in a downstream codebase (LASTRADA, see internal ticket DEV-6285) via SqlTrimmedFixedString<3> mapped to a WAEHRUNGEN.NAME CHAR(3) column storing 3-letter ISO 4217 codes (EUR, CHF, GBP, USD, …). These come back as 2-char prefixes ("EU", "CH", "GB", "US"), silently breaking ISO 4217 lookup.
Summary
SqlBasicStringOperations<SqlFixedString<N>>::OutputColumnand::GetColumnpassBufferLength = NtoSQLBindCol/SQLGetData. Per the ODBC spec,BufferLengthforSQL_C_CHARmust include space for the null terminator, so the driver truncates any value of length N to N − 1 data chars + null. Values shorter than N (with trailing whitespace padding from aCHAR(N)column) are unaffected because there is slack space.Code
src/Lightweight/DataBinder/BasicStringBinder.hpp,OutputColumn:SqlFixedString<N>actually allocates its buffer asT _data[N + 1](so the null-terminator slot exists), but the binder hands ODBC onlyNbytes.The same pattern exists in the
GetColumnoverload (~line 291):SQLGetData(..., AnsiStringType::Capacity, ...)passesNinstead ofN + 1.Reproducer
Pseudocode in Lightweight test style:
The existing
SqlFixedString: TrimRighttest intests/DataBinderTests.cppdoes not exercise this case — both calls passindicator < N(e.g.SqlTrimmedFixedString<20>{ "Hello " },TrimRight(&str, 5)), so they have plenty of slack and don't reach themin(indicator, N-1)boundary.Downstream consequence in
TrimRightSqlFixedString.hpp(around line 474):The
N - 1cap here is consistent with the truncated buffer (sinceSQLBindColcould only have writtenN − 1data chars). It is therefore not separately observable today. OnceBufferLengthis corrected toN + 1, this cap also needs to becomeN— otherwise_sizewould still be capped atN − 1even though the buffer now has the full value.Suggested fix
In
OutputColumnandGetColumn(andBatchOutputColumn/Reserve/Resizepaths if symmetric), passCapacity + 1instead ofSize(result)/Capacityfor types with a compile-timeCapacity. Change theN - 1cap inTrimRighttoN. Add a regression test where the stored value hasindicator == Nand the last byte is non-whitespace.Why this hasn't been observed before
The bug fires only when:
For most consumers of
SqlTrimmedFixedString<N>with N ≥ 10, real values rarely fill the buffer with no padding. We tripped over it in a downstream codebase (LASTRADA, see internal ticket DEV-6285) viaSqlTrimmedFixedString<3>mapped to aWAEHRUNGEN.NAME CHAR(3)column storing 3-letter ISO 4217 codes (EUR, CHF, GBP, USD, …). These come back as 2-char prefixes ("EU", "CH", "GB", "US"), silently breaking ISO 4217 lookup.