From e5ebfc01be53c38c5156baeef8e3b01cb8e119f4 Mon Sep 17 00:00:00 2001 From: Stephan Kieburg Date: Tue, 5 May 2026 20:30:49 +0200 Subject: [PATCH 1/3] test: SqlTrimmedFixedString round-trips a CHAR(N) value that fills capacity Adds a regression test (currently failing) for #485: when an entity field declared as Light::SqlTrimmedFixedString stores a value of length N with no trailing whitespace, the round-trip via dm.QuerySingle returns only N-1 characters. Surfaced downstream as LASTRADA DEV-6285 (empty cbc:DocumentCurrencyCode in XRechnung exports for non-Euro invoices, where 3-letter ISO 4217 codes in CHAR(3) columns came back as 2-char prefixes). --- src/tests/DataBinderTests.cpp | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/tests/DataBinderTests.cpp b/src/tests/DataBinderTests.cpp index e472ff22..13153e93 100644 --- a/src/tests/DataBinderTests.cpp +++ b/src/tests/DataBinderTests.cpp @@ -1450,6 +1450,35 @@ TEST_CASE_METHOD(SqlTestFixture, "Trimmed fixed strings strip trailing padding t CHECK(result->stringWide == SqlTrimmedWideFixedString<32> { L"Hellö" }); } +// Regression for #485: BasicStringBinder::OutputColumn passes BufferLength=N +// (not N+1) to SQLBindCol, so ODBC truncates any value of length N to N-1 +// data chars + null. The bug only surfaces when the stored value fills the +// full capacity with no trailing whitespace -- typical case is 3-letter +// ISO 4217 codes in a CHAR(3) column. Surfaced downstream as LASTRADA DEV-6285 +// (empty cbc:DocumentCurrencyCode in XRechnung exports for non-Euro invoices). +struct FullCapacityFixedRow +{ + Field id {}; + Field> code {}; +}; + +TEST_CASE_METHOD(SqlTestFixture, + "Trimmed fixed string preserves a value that fills its capacity", + "[DataMapper][SqlFixedString][regression]") +{ + auto dm = DataMapper {}; + dm.CreateTable(); + + FullCapacityFixedRow row {}; + row.code = "EUR"; + dm.Create(row); + + auto const result = dm.QuerySingle(row.id); + REQUIRE(result.has_value()); + CHECK(result->code.Value().size() == 3); + CHECK(result->code.Value().str() == "EUR"); +} + // Coverage for the SQL_C_WCHAR truncation arithmetic in // detail::GetRawColumnArrayData (BasicStringBinder.hpp). The function has two // re-fetch branches: (a) the driver reports total length up front and we re- From 3a29c5b24d7162a682e9e16c0a4ee3aeabb80c44 Mon Sep 17 00:00:00 2001 From: Stephan Kieburg Date: Tue, 5 May 2026 20:31:04 +0200 Subject: [PATCH 2/3] fix: pass BufferLength=Capacity+1 to SQLBindCol/SQLGetData for SqlFixedString Closes #485. ODBC's SQL_C_CHAR semantics require BufferLength to include space for the null terminator, so passing N (the field's Capacity) caused the driver to truncate any value of length N to N-1 data chars + null. SqlFixedString already allocates _data[N+1] to make room for the terminator, so the fix is to hand ODBC the full N+1 bytes it expects. The N-1 cap inside SqlBasicStringOperations::TrimRight was consistent with the truncated buffer (which never held more than N-1 data chars anyway). Once BufferLength is corrected, the cap must become N as well, otherwise _size would still be limited to N-1 even though the buffer now holds the full value. The bug was previously masked because the existing TrimRight test passes indicator < N (e.g. SqlTrimmedFixedString<20>{"Hello "}, indicator=5). The added regression test exercises indicator == N with a non-whitespace last byte. --- .../DataBinder/BasicStringBinder.hpp | 17 +++++++++++++++-- src/Lightweight/DataBinder/SqlFixedString.hpp | 4 ++-- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/Lightweight/DataBinder/BasicStringBinder.hpp b/src/Lightweight/DataBinder/BasicStringBinder.hpp index 7daf6287..090ccdb3 100644 --- a/src/Lightweight/DataBinder/BasicStringBinder.hpp +++ b/src/Lightweight/DataBinder/BasicStringBinder.hpp @@ -286,11 +286,22 @@ struct SqlDataBinder cb.PlanPostProcessOutputColumn( [stmt, column, indicator, result]() { PostProcessOutputColumn(stmt, column, result, indicator); }); + // Per ODBC spec, BufferLength for SQL_C_CHAR must include space for the + // null terminator. SqlFixedString backs its data with _data[N+1] for + // exactly this reason; pass Capacity+1 to let the driver write a full + // N-char value plus null. Dynamic strings keep the current behaviour. + SQLLEN const bufferLength = [result]() -> SQLLEN { + if constexpr (requires { AnsiStringType::Capacity; }) + return static_cast(AnsiStringType::Capacity) + 1; + else + return static_cast(StringTraits::Size(result)); + }(); + return SQLBindCol(stmt, column, SQL_C_CHAR, (SQLPOINTER) StringTraits::Data(result), - (SQLLEN) StringTraits::Size(result), + bufferLength, indicator); } @@ -338,8 +349,10 @@ struct SqlDataBinder if constexpr (requires { AnsiStringType::Capacity; }) { StringTraits::Resize(result, AnsiStringType::Capacity); + // BufferLength for SQL_C_CHAR must include space for the null terminator; + // _data is sized N+1 to accommodate it. SQLRETURN const rv = - SQLGetData(stmt, column, SQL_C_CHAR, StringTraits::Data(result), AnsiStringType::Capacity, indicator); + SQLGetData(stmt, column, SQL_C_CHAR, StringTraits::Data(result), AnsiStringType::Capacity + 1, indicator); if (rv == SQL_SUCCESS || rv == SQL_NO_DATA) { if (*indicator == SQL_NULL_DATA) diff --git a/src/Lightweight/DataBinder/SqlFixedString.hpp b/src/Lightweight/DataBinder/SqlFixedString.hpp index 4c59aaca..219ee09e 100644 --- a/src/Lightweight/DataBinder/SqlFixedString.hpp +++ b/src/Lightweight/DataBinder/SqlFixedString.hpp @@ -471,9 +471,9 @@ struct SqlBasicStringOperations> LIGHTWEIGHT_FORCE_INLINE static void TrimRight(ValueType* boundOutputString, SQLLEN indicator) noexcept { #if defined(_WIN32) - size_t n = (std::min) (static_cast(indicator) / sizeof(CharType), N - 1); + size_t n = (std::min) (static_cast(indicator) / sizeof(CharType), N); #else - size_t n = std::min(static_cast(indicator), N - 1); + size_t n = std::min(static_cast(indicator), N); #endif // Strip trailing whitespace and null characters while (n > 0 && (std::isspace((*boundOutputString)[n - 1]) || (*boundOutputString)[n - 1] == '\0')) From 08147c2ee331e3ef1aa3c86d0a87346143a12699 Mon Sep 17 00:00:00 2001 From: Stephan Kieburg Date: Tue, 5 May 2026 20:41:35 +0200 Subject: [PATCH 3/3] style: clang-format SQLBindCol call --- src/Lightweight/DataBinder/BasicStringBinder.hpp | 9 ++------- src/tests/DataBinderTests.cpp | 5 ++--- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/src/Lightweight/DataBinder/BasicStringBinder.hpp b/src/Lightweight/DataBinder/BasicStringBinder.hpp index 090ccdb3..f978c3a4 100644 --- a/src/Lightweight/DataBinder/BasicStringBinder.hpp +++ b/src/Lightweight/DataBinder/BasicStringBinder.hpp @@ -290,19 +290,14 @@ struct SqlDataBinder // null terminator. SqlFixedString backs its data with _data[N+1] for // exactly this reason; pass Capacity+1 to let the driver write a full // N-char value plus null. Dynamic strings keep the current behaviour. - SQLLEN const bufferLength = [result]() -> SQLLEN { + SQLLEN const bufferLength = [&]() -> SQLLEN { if constexpr (requires { AnsiStringType::Capacity; }) return static_cast(AnsiStringType::Capacity) + 1; else return static_cast(StringTraits::Size(result)); }(); - return SQLBindCol(stmt, - column, - SQL_C_CHAR, - (SQLPOINTER) StringTraits::Data(result), - bufferLength, - indicator); + return SQLBindCol(stmt, column, SQL_C_CHAR, (SQLPOINTER) StringTraits::Data(result), bufferLength, indicator); } static void PostProcessOutputColumn(SQLHSTMT stmt, SQLUSMALLINT column, AnsiStringType* result, SQLLEN* indicator) diff --git a/src/tests/DataBinderTests.cpp b/src/tests/DataBinderTests.cpp index 13153e93..e5263cb0 100644 --- a/src/tests/DataBinderTests.cpp +++ b/src/tests/DataBinderTests.cpp @@ -1453,9 +1453,8 @@ TEST_CASE_METHOD(SqlTestFixture, "Trimmed fixed strings strip trailing padding t // Regression for #485: BasicStringBinder::OutputColumn passes BufferLength=N // (not N+1) to SQLBindCol, so ODBC truncates any value of length N to N-1 // data chars + null. The bug only surfaces when the stored value fills the -// full capacity with no trailing whitespace -- typical case is 3-letter -// ISO 4217 codes in a CHAR(3) column. Surfaced downstream as LASTRADA DEV-6285 -// (empty cbc:DocumentCurrencyCode in XRechnung exports for non-Euro invoices). +// full capacity with no trailing whitespace -- e.g. a 3-letter currency code +// stored in a CHAR(3) column. struct FullCapacityFixedRow { Field id {};