Skip to content

SqlFixedString: BufferLength=N passed to SQLBindCol/SQLGetData truncates values that fill the buffer #485

@StepKie

Description

@StepKie

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:

  1. The stored value is exactly N chars, and
  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions