From 6605f9f9262d9cccc9e0618b2ce676081fdcb5bf Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 17:01:00 +0000 Subject: [PATCH 1/2] Fix 64-bit integer truncation and REAL precision loss on fetch (#17) GetColumnValue stringified every fetched column into a std::wstring before hydrating it back into the typed member. Two of these conversions were lossy: - SQLITE_INTEGER was read with the 32-bit sqlite3_column_int, silently truncating/wrapping any stored int64_t above INT32_MAX. - SQLITE_FLOAT was formatted with std::to_wstring(double), which uses %f with a fixed 6 decimal places, corrupting high-precision or large-magnitude doubles on read. Switch to sqlite3_column_int64 for integers, and format doubles with %.17g (full round-trip precision) instead of to_wstring. TEXT/BOOL/DATETIME handling is untouched. Add regression tests covering an int64 value beyond INT32_MAX and high-precision/large-magnitude doubles, verified to fail before this change and pass after. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01NUt3c1wdseRtRSSXfg3MCK --- src/queries.cc | 14 ++++++++++---- tests/database_test.cc | 42 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/src/queries.cc b/src/queries.cc index 46bd366..9acde85 100644 --- a/src/queries.cc +++ b/src/queries.cc @@ -25,6 +25,7 @@ #include #include +#include #include #include #include @@ -374,10 +375,15 @@ std::wstring FetchRecordsQuery::GetColumnValue(const int col) const { const int col_type = sqlite3_column_type(stmt_, col); switch (col_type) { case SQLITE_INTEGER: - return std::to_wstring(sqlite3_column_int(stmt_, col)); - - case SQLITE_FLOAT: - return std::to_wstring(sqlite3_column_double(stmt_, col)); + return std::to_wstring(sqlite3_column_int64(stmt_, col)); + + case SQLITE_FLOAT: { + // %.17g round-trips any double exactly; to_wstring's fixed 6-decimal + // formatting would silently truncate precision here + char buffer[64]; + std::snprintf(buffer, sizeof(buffer), "%.17g", sqlite3_column_double(stmt_, col)); + return StringUtilities::FromUtf8(buffer, std::strlen(buffer)); + } case SQLITE_TEXT: { const auto content = reinterpret_cast(sqlite3_column_text(stmt_, col)); diff --git a/tests/database_test.cc b/tests/database_test.cc index ed55737..0c64bfc 100644 --- a/tests/database_test.cc +++ b/tests/database_test.cc @@ -571,6 +571,48 @@ TEST_F(DatabaseTest, FetchWithPredicateChaining) { EXPECT_EQ(37, fetched_persons[1].age); } +TEST_F(DatabaseTest, FetchPreservesInt64ValuesBeyondInt32Range) { + const auto db = Database::Instance(); + + // Values above INT32_MAX must survive a round-trip; sqlite3_column_int would + // truncate/wrap these to 32 bits + const int64_t above_int32_max = 5000000000LL; + const int64_t near_int64_max = 9000000000000000000LL; + + std::vector company; + company.push_back({L"Big Corp", above_int32_max, L"Nowhere", 1.0, 1}); + company.push_back({L"Huge Corp", near_int64_max, L"Nowhere", 1.0, 2}); + + db->Save(company); + + const auto fetched_first = db->Fetch(1); + EXPECT_EQ(above_int32_max, fetched_first.age); + + const auto fetched_second = db->Fetch(2); + EXPECT_EQ(near_int64_max, fetched_second.age); +} + +TEST_F(DatabaseTest, FetchPreservesHighPrecisionDoubleValues) { + const auto db = Database::Instance(); + + // std::to_wstring(double) formats with a fixed 6 decimal places, which would + // silently drop precision here + const double high_precision = 0.12345678901234567; + const double large_magnitude = 123456789012345.67; + + std::vector company; + company.push_back({L"Precision Inc", 1, L"Nowhere", high_precision, 1}); + company.push_back({L"Large Corp", 2, L"Nowhere", large_magnitude, 2}); + + db->Save(company); + + const auto fetched_first = db->Fetch(1); + EXPECT_DOUBLE_EQ(high_precision, fetched_first.salary); + + const auto fetched_second = db->Fetch(2); + EXPECT_DOUBLE_EQ(large_magnitude, fetched_second.salary); +} + TEST_F(DatabaseTest, RawSqlQueryForPersistedRecord) { const auto db = Database::Instance(); From 6510b103caf03a9dd3c4b40660f23be0b815e072 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 19:33:07 +0000 Subject: [PATCH 2/2] Remove unused Join(char) overload and unused include StringUtilities::Join(list, char) has no call sites; only the std::string-separator overload is used. in string_utilities.cc is likewise unused (conversions go through 's wstring_convert). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01NUt3c1wdseRtRSSXfg3MCK --- src/internal/string_utilities.h | 1 - src/string_utilities.cc | 5 ----- 2 files changed, 6 deletions(-) diff --git a/src/internal/string_utilities.h b/src/internal/string_utilities.h index 79b87d9..5e97a25 100644 --- a/src/internal/string_utilities.h +++ b/src/internal/string_utilities.h @@ -41,6 +41,5 @@ class REFLECTION_EXPORT StringUtilities { static std::wstring FromUtf8(const char* utf8_string, size_t byte_count); static std::string Join(const std::vector& list, const std::string& separator); - static std::string Join(const std::vector& list, char c); }; } // namespace sqlite_reflection diff --git a/src/string_utilities.cc b/src/string_utilities.cc index 508568e..9622a1b 100644 --- a/src/string_utilities.cc +++ b/src/string_utilities.cc @@ -24,7 +24,6 @@ #include #include -#include #ifndef _WIN32 #include #endif @@ -86,7 +85,3 @@ std::string StringUtilities::Join(const std::vector& list, const st return std::accumulate(list.begin() + 1, list.end(), list[0], [&](const std::string& a, const std::string& b) { return a + separator + b; }); } - -std::string StringUtilities::Join(const std::vector& list, char c) { - return Join(list, std::string(1, c)); -}