Skip to content

Fix #485: SqlFixedString round-trip truncates values that fill capacity#486

Open
StepKie wants to merge 4 commits intomasterfrom
regression/issue-485-sqlfixedstring-fullcapacity
Open

Fix #485: SqlFixedString round-trip truncates values that fill capacity#486
StepKie wants to merge 4 commits intomasterfrom
regression/issue-485-sqlfixedstring-fullcapacity

Conversation

@StepKie
Copy link
Copy Markdown
Contributor

@StepKie StepKie commented May 5, 2026

Closes #485.

Problem

BasicStringBinder<...>::OutputColumn and ::GetColumn were passing BufferLength = Capacity (i.e., N) to SQLBindCol / SQLGetData when binding an SqlFixedString<N>-derived field. ODBC's SQL_C_CHAR semantics require BufferLength to include space for the null terminator, so the driver truncated any value of length N to N − 1 data chars + null.

SqlFixedString<N> already backs its data with _data[N + 1] so the terminator slot exists; the binder just wasn't telling ODBC about it.

The bug was masked for almost every consumer because real-world values rarely fill the full capacity with no trailing whitespace. We tripped over it in a downstream codebase (LASTRADA, ticket DEV-6285) via Light::Field<Light::SqlTrimmedFixedString<3>> mapped to a WAEHRUNGEN.NAME CHAR(3) column storing 3-letter ISO 4217 codes (EUR, CHF, GBP, USD, …). The codes came back as 2-char prefixes ("EU", "CH", "GB", "US"), silently breaking ISO 4217 lookup and producing an empty cbc:DocumentCurrencyCode in XRechnung exports for non-Euro invoices.

Approach

TDD — failing test in commit 1, fix in commit 2.

Test (commit 1, e5ebfc01)

Adds Trimmed fixed string preserves a value that fills its capacity next to the existing UnicodeTrimmedFixedRow test in DataBinderTests.cpp. It uses SqlTrimmedFixedString<3> and the value "EUR" to hit indicator == N with a non-whitespace last byte — the precise condition the existing SqlFixedString: TrimRight test does not cover (its calls pass indicator < N and so always have slack).

Fix (commit 2, 3a29c5b2)

Two binder paths and one trim path:

  1. BasicStringBinder.hpp::OutputColumn — for fixed-capacity types, pass Capacity + 1 to SQLBindCol instead of Size(result). Dynamic strings keep their existing behaviour.
  2. BasicStringBinder.hpp::GetColumn (the if constexpr (requires { Capacity; }) branch) — pass Capacity + 1 to SQLGetData.
  3. SqlFixedString.hpp::TrimRight — change the cap on n from N − 1 to N. The previous N − 1 was consistent with the truncated buffer (since ODBC could only have written N − 1 data chars anyway). With BufferLength corrected, capping at N − 1 would leave _size permanently one short of the actual content.

Existing tests are unaffected because they all operate on values shorter than N with trailing whitespace, where both N − 1 and N give the same trim result.

Test matrix

I haven't been able to run Lightweight's full test suite locally (no preconfigured ODBC test DB on this box). CI on this PR will exercise both the new regression test and the rest of the suite across the supported drivers. Happy to iterate if anything else trips.

Stephan Kieburg added 2 commits May 5, 2026 20:30
… capacity

Adds a regression test (currently failing) for #485: when an entity field
declared as Light::SqlTrimmedFixedString<N> 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).
…edString<N>

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<N>
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.
@StepKie StepKie requested a review from a team as a code owner May 5, 2026 18:32
@github-actions github-actions Bot added tests Data Binder SQL Data Binder support labels May 5, 2026
Comment on lines +289 to 306
// Per ODBC spec, BufferLength for SQL_C_CHAR must include space for the
// null terminator. SqlFixedString<N> 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<SQLLEN>(AnsiStringType::Capacity) + 1;
else
return static_cast<SQLLEN>(StringTraits::Size(result));
}();

return SQLBindCol(stmt,
column,
SQL_C_CHAR,
(SQLPOINTER) StringTraits::Data(result),
(SQLLEN) StringTraits::Size(result),
bufferLength,
indicator);
}
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This if -else lambda construction does not strike me as very elegant.

this is just what was generated by Claude. Feel free to replace if you find a solution less offensive to the eye that still passes the test.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is the pattern we use in the code to make variables const

Copy link
Copy Markdown
Member

@Yaraslaut Yaraslaut left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, this is quite surprising that we get this issue, is it something specific to the trimmed strings? since we have plenty of tests for other string types

Comment on lines +1457 to +1458
// ISO 4217 codes in a CHAR(3) column. Surfaced downstream as LASTRADA DEV-6285
// (empty cbc:DocumentCurrencyCode in XRechnung exports for non-Euro invoices).
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

internal ticket mentioned in public repo

Comment on lines +289 to 306
// Per ODBC spec, BufferLength for SQL_C_CHAR must include space for the
// null terminator. SqlFixedString<N> 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<SQLLEN>(AnsiStringType::Capacity) + 1;
else
return static_cast<SQLLEN>(StringTraits::Size(result));
}();

return SQLBindCol(stmt,
column,
SQL_C_CHAR,
(SQLPOINTER) StringTraits::Data(result),
(SQLLEN) StringTraits::Size(result),
bufferLength,
indicator);
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is the pattern we use in the code to make variables const

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Core API Data Binder SQL Data Binder support tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants