From c7df821b5df3014e5a2e2861e3d0bd0e87e911b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Rombauts?= Date: Sat, 24 Oct 2026 20:52:41 +0200 Subject: [PATCH 1/3] Add nullable Column::getOptional() accessor Return std::nullopt for SQL NULL and reuse existing value conversions otherwise. Cover NULL, zero, empty strings and BLOBs, embedded NUL bytes, and borrowed pointer and view types. --- include/SQLiteCpp/Column.h | 38 +++++++++++++++++++++ tests/Column_test.cpp | 67 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+) diff --git a/include/SQLiteCpp/Column.h b/include/SQLiteCpp/Column.h index 2679232c..c3d07999 100644 --- a/include/SQLiteCpp/Column.h +++ b/include/SQLiteCpp/Column.h @@ -16,7 +16,9 @@ #include #include +#include #include +#include // Forward declarations to avoid inclusion of in a header struct sqlite3_stmt; @@ -268,6 +270,42 @@ class SQLITECPP_API Column return getStringView(); } + /** + * @brief Return the column value as an optional, preserving SQL NULL. + * + * Uses the string accessors for std::string and std::string_view, and the existing Column + * conversion operators for other types. NULL is distinct from an empty string or zero. + * + * @tparam T Destination value type, such as int, double, std::string, or std::string_view. + * @return std::nullopt for SQL NULL; otherwise the converted value. + * @note std::optional indicates whether the value is NULL; it does not imply ownership. + * @note Check for NULL before any other type conversions of the same column. + * @warning If T is a pointer or view, the result borrows SQLite-owned memory and may be + * invalidated by column conversions or stepping, resetting, or finalizing the statement. + * Use an owning T (e.g. std::string) to retain the value independently. + * @note A zero-length BLOB may produce an engaged optional containing nullptr. + */ + template + std::optional getOptional() const + { + if (isNull()) + { + return std::nullopt; + } + if constexpr (std::is_same_v) + { + return getString(); + } + else if constexpr (std::is_same_v) + { + return getStringView(); + } + else + { + return static_cast(*this); + } + } + private: Statement::TStatementPtr mStmtPtr; ///< Shared Pointer to the prepared SQLite Statement Object int mIndex; ///< Index of the column in the row of result, starting at 0 diff --git a/tests/Column_test.cpp b/tests/Column_test.cpp index 0c640ac4..8b182bf5 100644 --- a/tests/Column_test.cpp +++ b/tests/Column_test.cpp @@ -19,6 +19,73 @@ #include +TEST(Column, getOptional) +{ + SQLite::Database db(":memory:", SQLite::OPEN_READWRITE | SQLite::OPEN_CREATE); + ASSERT_EQ(0, db.exec("CREATE TABLE test (number INTEGER, amount REAL, text TEXT, data BLOB)")); + ASSERT_EQ(1, db.exec("INSERT INTO test VALUES (NULL, NULL, NULL, NULL)")); + ASSERT_EQ(1, db.exec("INSERT INTO test VALUES (0, 0.0, '', X'')")); + ASSERT_EQ(1, db.exec("INSERT INTO test VALUES (42, 1.5, 'A' || char(0) || 'B', X'410042')")); + + // Read each SQLite result column through only one conversion path. + SQLite::Statement query(db, + "SELECT number, number, amount, text, text, data, data FROM test ORDER BY rowid"); + + ASSERT_TRUE(query.executeStep()); + EXPECT_FALSE(query.getColumn(0).getOptional().has_value()); + EXPECT_FALSE(query.getColumn(1).getOptional().has_value()); + EXPECT_FALSE(query.getColumn(2).getOptional().has_value()); + EXPECT_FALSE(query.getColumn(3).getOptional().has_value()); + EXPECT_FALSE(query.getColumn(4).getOptional().has_value()); + EXPECT_FALSE(query.getColumn(5).getOptional().has_value()); + EXPECT_FALSE(query.getColumn(6).getOptional().has_value()); + + ASSERT_TRUE(query.executeStep()); + const auto zero = query.getColumn(0).getOptional(); + ASSERT_TRUE(zero.has_value()); + EXPECT_EQ(0, *zero); + const auto zeroReal = query.getColumn(2).getOptional(); + ASSERT_TRUE(zeroReal.has_value()); + EXPECT_DOUBLE_EQ(0.0, *zeroReal); + const auto emptyText = query.getColumn(3).getOptional(); + ASSERT_TRUE(emptyText.has_value()); + EXPECT_TRUE(emptyText->empty()); + const auto emptyPointer = query.getColumn(4).getOptional(); + ASSERT_TRUE(emptyPointer.has_value()); + ASSERT_NE(nullptr, *emptyPointer); + EXPECT_STREQ("", *emptyPointer); + const auto emptyBlob = query.getColumn(5).getOptional(); + ASSERT_TRUE(emptyBlob.has_value()); + EXPECT_TRUE(emptyBlob->empty()); + const auto emptyBlobPointer = query.getColumn(6).getOptional(); + ASSERT_TRUE(emptyBlobPointer.has_value()); + EXPECT_EQ(nullptr, *emptyBlobPointer); + + ASSERT_TRUE(query.executeStep()); + const auto number = query.getColumn(1).getOptional(); + ASSERT_TRUE(number.has_value()); + EXPECT_EQ(42, *number); + const auto amount = query.getColumn(2).getOptional(); + ASSERT_TRUE(amount.has_value()); + EXPECT_DOUBLE_EQ(1.5, *amount); + const std::string embedded({'A', char(0), 'B'}); + const auto text = query.getColumn(3).getOptional(); + ASSERT_TRUE(text.has_value()); + EXPECT_EQ(embedded, *text); + const auto textView = query.getColumn(4).getOptional(); + ASSERT_TRUE(textView.has_value()); + EXPECT_EQ(std::string_view(embedded), *textView); + const auto blob = query.getColumn(5).getOptional(); + ASSERT_TRUE(blob.has_value()); + EXPECT_EQ(embedded, *blob); + const auto blobView = query.getColumn(6).getOptional(); + ASSERT_TRUE(blobView.has_value()); + EXPECT_EQ(std::string_view(embedded), *blobView); + + EXPECT_FALSE(query.executeStep()); +} + + TEST(Column, typeConstantsAsCaseLabels) { SQLite::Database db(":memory:", SQLite::OPEN_READWRITE | SQLite::OPEN_CREATE); From d724602d56aa470b93a1dc2658a70ce9ad1a54a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Rombauts?= Date: Sat, 24 Oct 2026 20:52:44 +0200 Subject: [PATCH 2/3] docs: explain ownership of SQLite column results Distinguish owned strings from borrowed pointers and views, including optional values and their lifetime constraints. --- README.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/README.md b/README.md index dc7956cb..fc6b3eae 100644 --- a/README.md +++ b/README.md @@ -365,6 +365,19 @@ until the parameter is rebound or the statement is finalized. Calling `reset()` does not release that binding. The view returned by `getStringView()` also borrows SQLite-owned memory; copy it if it must outlive the current row. +### Owning and borrowing column values + +`Column::getString()` and `getOptional()` return owning copies. +For non-NULL columns, `getStringView()`, `getText(nullptr)`, and +`getOptional()` return non-owning views or pointers into +SQLite-managed buffers. These avoid copies, but the data may be invalidated by +column type conversions or by stepping, resetting, or finalizing the statement. + +`std::optional` only preserves SQL NULL; it does not make `T` owning. +This is similar to Rust's borrowed `&str` versus owned `String`, except C++ +does not enforce borrowing lifetimes at compile time. Use an owning type when +the value must outlive the current SQLite result. + ### The first sample demonstrates how to query a database and get results: ```C++ From c8c4468b69886dbd3b4ed0eb110d628cc9f90d14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Rombauts?= Date: Sat, 24 Oct 2026 20:52:49 +0200 Subject: [PATCH 3/3] docs: record optional column accessor Add the new nullable value accessor to the 4.0 changelog (#614). --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 160514e7..bccb0281 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add source table-name accessors to Statement and Column (#594) - Add `std::string_view` binding and column access, with tests and documentation (#518) (#607) +- Add `Column::getOptional()` to preserve SQL NULL in typed column values (#614) ## [3.4.0] - 2026-09-21