Summary
SqlGuid::Create() (the Win32 native-GUID path) and SqlGuid::TryParse() / the std::formatter<SqlGuid> specialization write and read data[16] in two different, incompatible byte orders. Any GUID that is formatted to text via to_string()/std::format and later re-parsed via TryParse() (or vice versa) round-trips to a different physical GUID once it crosses the ODBC SQL_C_GUID boundary, even though the two textual representations look unrelated at a glance.
Root cause
-
Create()'s Win32 path (src/Lightweight/DataBinder/SqlGuid.cpp) builds data[] by splitting a GUID's Data1/Data2/Data3 fields big-endian:
guid.data[0] = winGuid.Data1 >> 24;
guid.data[1] = winGuid.Data1 >> 16;
guid.data[2] = winGuid.Data1 >> 8;
guid.data[3] = winGuid.Data1;
This matches the native GUID/SQL_C_GUID in-memory layout, so Create()'s output binds correctly via InputParameter's SQLBindParameter(..., SQL_C_GUID, ...).
-
TryParse() (SqlGuid.hpp/SqlGuid.cpp) instead fills data[] straight from consecutive hex-pair positions in the text, i.e. data[0] is the first byte as written in the string (xxxxxxxx-...) — the opposite convention from Create().
-
std::formatter<SqlGuid>::format() (SqlGuid.hpp) reconstructs Data1/Data2/Data3 from data[] by reversing the byte order:
(uint32_t) guid.data[3] | (uint32_t) guid.data[2] << 8 |
(uint32_t) guid.data[1] << 16 | (uint32_t) guid.data[0] << 24
This is the correct inverse of Create()'s layout, but the wrong inverse of TryParse()'s layout.
So TryParse and format/to_string form a self-consistent round-trip pair (parse text -> format back to the same text), and Create and the ODBC binding form a separate self-consistent pair (generate -> bind -> matches what SQL Server itself considers that GUID to be) - but the two pairs disagree with each other. A GUID created via Create(), inserted, then later formatted via to_string() for display/storage-as-text (e.g. embedding a uniqueidentifier primary key in an unrelated text/XML field) produces a string that does not match what SELECT ... WHERE col = '<that string>' finds in the database - because format() un-reverses bytes that Create() never reversed in the first place.
Reproduction
Observed downstream in an application: a SqlGuid::Create()'d value was inserted as a uniqueidentifier primary key, then Light::to_string()'d for storage elsewhere. Querying the target table with the exact string to_string() produced returned 0 rows; the actual stored row's NR, as SQL Server itself renders it (sqlcmd, SELECT NR FROM t), was:
to_string() output: 4B0BA16D-1A21-4BDA-881D-5550E28B7858
SQL Server's own value: 6DA10B4B-211A-DA4B-881D-5550E28B7858
Confirmed via direct query: SELECT NR FROM t WHERE NR = '6DA10B4B-211A-DA4B-881D-5550E28B7858' matches the row; ... WHERE NR = '4B0BA16D-1A21-4BDA-881D-5550E28B7858' returns nothing. Note the first three hex groups are exactly byte-reversed between the two strings (4B0BA16D <-> 6DA10B4B byte-swapped, etc.); the last two groups (881D-5550E28B7858) are identical, matching format()'s big-endian-only treatment of data[10..15].
Why this matters beyond cosmetics
- Round-trips correctly only through one path at a time. An app that always uses
TryParse+format/to_string together (never touching Create()/ODBC binding for the same value) or always uses Create()+ODBC binding together (never formatting to text) won't notice. Mixing them - e.g. Create() a GUID, insert it, then to_string() it for embedding in a different record as a foreign reference, then TryParse() that string back later to query - silently produces a lookup that either fails or (worse, if both write and read paths apply the same internal inconsistency) succeeds while the portable, cross-tool-correct string is wrong.
- Breaks cross-backend/cross-tool correctness. SQL Server's
uniqueidentifier mixed-endian convention (Data1/Data2/Data3 little-endian in storage, last two groups big-endian) is the reason this asymmetry exists at all; it doesn't apply to Postgres uuid or SQLite (stored as plain text via to_string() per this file's own SQLITE branches). Any consumer outside this library that reads the raw column with a standards-correct driver, or the same value round-tripped through a different backend, will see a different/wrong GUID.
Suggested fix
Make TryParse() fill data[] in the same physical byte order Create() uses (i.e. apply the same big-endian folding for the first three groups that Create()'s Win32 path already does), so Create(), TryParse(), format()/to_string(), and the ODBC SQL_C_GUID binding all agree on one internal representation. Add a round-trip test: TryParse(x) -> ODBC insert -> read back via a raw/independent query (or at minimum assert to_string(TryParse(x).value()) == x and that the resulting bytes match what Create() would produce for an equivalent value) to catch this class of regression.
Environment
- Pinned vcpkg tag in the downstream project:
v0.20260625.0
- Confirmed still present on
Lightweight's current master (8b76d5ca) as of this report.
Summary
SqlGuid::Create()(the Win32 native-GUID path) andSqlGuid::TryParse()/ thestd::formatter<SqlGuid>specialization write and readdata[16]in two different, incompatible byte orders. Any GUID that is formatted to text viato_string()/std::formatand later re-parsed viaTryParse()(or vice versa) round-trips to a different physical GUID once it crosses the ODBCSQL_C_GUIDboundary, even though the two textual representations look unrelated at a glance.Root cause
Create()'s Win32 path (src/Lightweight/DataBinder/SqlGuid.cpp) buildsdata[]by splitting aGUID'sData1/Data2/Data3fields big-endian:This matches the native
GUID/SQL_C_GUIDin-memory layout, soCreate()'s output binds correctly viaInputParameter'sSQLBindParameter(..., SQL_C_GUID, ...).TryParse()(SqlGuid.hpp/SqlGuid.cpp) instead fillsdata[]straight from consecutive hex-pair positions in the text, i.e.data[0]is the first byte as written in the string (xxxxxxxx-...) — the opposite convention fromCreate().std::formatter<SqlGuid>::format()(SqlGuid.hpp) reconstructsData1/Data2/Data3fromdata[]by reversing the byte order:This is the correct inverse of
Create()'s layout, but the wrong inverse ofTryParse()'s layout.So
TryParseandformat/to_stringform a self-consistent round-trip pair (parse text -> format back to the same text), andCreateand the ODBC binding form a separate self-consistent pair (generate -> bind -> matches what SQL Server itself considers that GUID to be) - but the two pairs disagree with each other. A GUID created viaCreate(), inserted, then later formatted viato_string()for display/storage-as-text (e.g. embedding auniqueidentifierprimary key in an unrelated text/XML field) produces a string that does not match whatSELECT ... WHERE col = '<that string>'finds in the database - becauseformat()un-reverses bytes thatCreate()never reversed in the first place.Reproduction
Observed downstream in an application: a
SqlGuid::Create()'d value was inserted as auniqueidentifierprimary key, thenLight::to_string()'d for storage elsewhere. Querying the target table with the exact stringto_string()produced returned 0 rows; the actual stored row'sNR, as SQL Server itself renders it (sqlcmd,SELECT NR FROM t), was:Confirmed via direct query:
SELECT NR FROM t WHERE NR = '6DA10B4B-211A-DA4B-881D-5550E28B7858'matches the row;... WHERE NR = '4B0BA16D-1A21-4BDA-881D-5550E28B7858'returns nothing. Note the first three hex groups are exactly byte-reversed between the two strings (4B0BA16D<->6DA10B4Bbyte-swapped, etc.); the last two groups (881D-5550E28B7858) are identical, matchingformat()'s big-endian-only treatment ofdata[10..15].Why this matters beyond cosmetics
TryParse+format/to_stringtogether (never touchingCreate()/ODBC binding for the same value) or always usesCreate()+ODBC binding together (never formatting to text) won't notice. Mixing them - e.g.Create()a GUID, insert it, thento_string()it for embedding in a different record as a foreign reference, thenTryParse()that string back later to query - silently produces a lookup that either fails or (worse, if both write and read paths apply the same internal inconsistency) succeeds while the portable, cross-tool-correct string is wrong.uniqueidentifiermixed-endian convention (Data1/Data2/Data3little-endian in storage, last two groups big-endian) is the reason this asymmetry exists at all; it doesn't apply to Postgresuuidor SQLite (stored as plain text viato_string()per this file's own SQLITE branches). Any consumer outside this library that reads the raw column with a standards-correct driver, or the same value round-tripped through a different backend, will see a different/wrong GUID.Suggested fix
Make
TryParse()filldata[]in the same physical byte orderCreate()uses (i.e. apply the same big-endian folding for the first three groups thatCreate()'s Win32 path already does), soCreate(),TryParse(),format()/to_string(), and the ODBCSQL_C_GUIDbinding all agree on one internal representation. Add a round-trip test:TryParse(x)-> ODBC insert -> read back via a raw/independent query (or at minimum assertto_string(TryParse(x).value()) == xand that the resulting bytes match whatCreate()would produce for an equivalent value) to catch this class of regression.Environment
v0.20260625.0Lightweight's currentmaster(8b76d5ca) as of this report.