From 5806a127c98bc3bf1d755127f88677ef6b9e7a54 Mon Sep 17 00:00:00 2001 From: jkalias Date: Sat, 18 Jul 2026 23:36:44 +0200 Subject: [PATCH 1/5] Implement nullable reflected fields --- CMakeLists.txt | 7 +- README.md | 9 +++ include/query_predicates.h | 85 ++++++++++++++++++++ include/reflection.h | 43 ++++++++++- src/CMakeLists.txt | 2 + src/queries.cc | 137 ++++++++++++++++++++++++++++----- src/query_predicates.cc | 32 +++++++- tests/database_test.cc | 104 +++++++++++++++++++++++++ tests/nullable_record.h | 33 ++++++++ tests/query_predicates_test.cc | 21 +++++ 10 files changed, 448 insertions(+), 25 deletions(-) create mode 100644 tests/nullable_record.h diff --git a/CMakeLists.txt b/CMakeLists.txt index c480b8b..daa793b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -22,7 +22,12 @@ FetchContent_Declare( googletest URL https://github.com/google/googletest/archive/refs/tags/release-1.12.1.zip ) -FetchContent_MakeAvailable(googletest) +FetchContent_Declare( + functional_cpp + URL https://github.com/jkalias/functional_cpp/archive/refs/tags/1.1.1.zip + SOURCE_SUBDIR src +) +FetchContent_MakeAvailable(googletest functional_cpp) # Set compiler settings based on the current platform if(CMAKE_HOST_UNIX) diff --git a/README.md b/README.md index c09c8cc..8a23fc3 100644 --- a/README.md +++ b/README.md @@ -133,8 +133,17 @@ Supported field macros: | `std::wstring` | `MEMBER_TEXT(name)` | | `bool` | `MEMBER_BOOL(name)` | | `sqlite_reflection::TimePoint` | `MEMBER_DATETIME(name)` | +| `fcpp::optional_t` | `MEMBER_INT_NULLABLE(name)` | +| `fcpp::optional_t` | `MEMBER_REAL_NULLABLE(name)` | +| `fcpp::optional_t` | `MEMBER_TEXT_NULLABLE(name)` | +| `fcpp::optional_t` | `MEMBER_BOOL_NULLABLE(name)` | +| `fcpp::optional_t` | `MEMBER_DATETIME_NULLABLE(name)` | | member function declaration | `FUNC(signature)` | +Nullable macros store SQL `NULL` as an empty optional and bind an empty optional back as SQL `NULL`; present optional values round-trip using the same storage class as their non-nullable counterparts, so `NULL`, an empty string, and numeric/boolean zero remain distinguishable after fetch. The optional type comes from [`functional_cpp`](https://github.com/jkalias/functional_cpp): under C++17 and later `fcpp::optional_t` aliases `std::optional`, while C++11 builds use the library fallback. Use `IsNull(&T::field)` and `IsNotNull(&T::field)` predicates for null checks. Value predicates such as `Equal(&T::nullable_field, value)` compare against a present contained value; pass the contained `T` value, not an optional. + +**Schema nullability caveat.** Newly created tables now declare non-nullable reflected fields as `NOT NULL` and omit `NOT NULL` for nullable fields. Because initialization uses `CREATE TABLE IF NOT EXISTS`, existing database files keep their previous column definitions until you recreate or migrate those tables, just like the `AUTOINCREMENT` caveat below. + **Layout constraint.** Reflectable records must be simple, standard-layout structs: no base classes, no virtual functions, no virtual/multiple inheritance. Member access is computed from `offsetof`/pointer-to-member byte offsets, which are only well-defined for such types; a struct diff --git a/include/query_predicates.h b/include/query_predicates.h index e29b275..630fcb3 100644 --- a/include/query_predicates.h +++ b/include/query_predicates.h @@ -42,6 +42,7 @@ struct REFLECTION_EXPORT SqlValue { bool bool_value; double real_value; std::string text_value; + bool is_null; }; /// The base class of all WHERE predicates used in SQLite queries @@ -103,6 +104,12 @@ class REFLECTION_EXPORT QueryPredicate : public QueryPredicateBase { : QueryPredicate(fn, value, symbol, [&](void* v, SqliteStorageClass storage_class) { return GetSqlValue(v, storage_class); }) {} + template + QueryPredicate(fcpp::optional_t T::* fn, R value, const std::string& symbol) + : QueryPredicate(fn, fcpp::optional_t(value), symbol, [&](void* v, SqliteStorageClass storage_class) { + return GetOptionalSqlValue(v, storage_class); + }) {} + QueryPredicate(const std::string& symbol, const std::string& member_name, const SqlValue& value) : symbol_(symbol), member_name_(member_name), value_(value) {} @@ -110,6 +117,7 @@ class REFLECTION_EXPORT QueryPredicate : public QueryPredicateBase { /// (defined from the pointer-to-member function) will be compared. The value needs /// to be type-erased, so that the header file is not bloated with unnecessary implementation details virtual SqlValue GetSqlValue(void* v, SqliteStorageClass storage_class) const; + virtual SqlValue GetOptionalSqlValue(void* v, SqliteStorageClass storage_class) const; /// The symbol used for the comparison, for example "=" for equality std::string symbol_; @@ -122,6 +130,47 @@ class REFLECTION_EXPORT QueryPredicate : public QueryPredicateBase { SqlValue value_; }; +/// A wrapper for an empty predicate, used to fetch all elements of an SQLite table +class REFLECTION_EXPORT NullPredicate : public QueryPredicateBase { +public: + template + NullPredicate(R T::* fn, const std::string& symbol) : symbol_(symbol) { + auto record = GetRecordFromTypeId(typeid(T).name()); + auto offset = OffsetFromStart(fn); + for (auto i = 0; i < record.member_metadata.size(); ++i) { + if (record.member_metadata[i].offset == offset) { + member_name_ = record.member_metadata[i].name; + return; + } + } + throw std::runtime_error("No registered member of '" + record.name + + "' matches the given pointer-to-member (type id: " + typeid(T).name() + ")"); + } + + std::string Evaluate() const override; + std::vector Bindings() const override; + QueryPredicateBase* Clone() const override; + +protected: + NullPredicate(const std::string& symbol, const std::string& member_name) + : symbol_(symbol), member_name_(member_name) {} + + std::string symbol_; + std::string member_name_; +}; + +class REFLECTION_EXPORT IsNull final : public NullPredicate { +public: + template + explicit IsNull(R T::* fn) : NullPredicate(fn, "IS NULL") {} +}; + +class REFLECTION_EXPORT IsNotNull final : public NullPredicate { +public: + template + explicit IsNotNull(R T::* fn) : NullPredicate(fn, "IS NOT NULL") {} +}; + /// A wrapper for an empty predicate, used to fetch all elements of an SQLite table class REFLECTION_EXPORT EmptyPredicate final : public QueryPredicateBase { public: @@ -137,6 +186,9 @@ class REFLECTION_EXPORT Equal final : public QueryPredicate { template explicit Equal(R T::* fn, R value) : QueryPredicate(fn, value, "=") {} + template + explicit Equal(fcpp::optional_t T::* fn, R value) : QueryPredicate(fn, value, "=") {} + template explicit Equal(int64_t T::* fn, int value) : Equal(fn, (int64_t)value) {} @@ -151,6 +203,9 @@ class REFLECTION_EXPORT Unequal final : public QueryPredicate { template explicit Unequal(R T::* fn, R value) : QueryPredicate(fn, value, "!=") {} + template + explicit Unequal(fcpp::optional_t T::* fn, R value) : QueryPredicate(fn, value, "!=") {} + template explicit Unequal(int64_t T::* fn, int value) : Unequal(fn, (int64_t)value) {} @@ -167,6 +222,12 @@ class REFLECTION_EXPORT Like final : public QueryPredicate { : QueryPredicate(fn, value, "LIKE", [&](void* v, SqliteStorageClass storage_class) { return GetSqlValue(v, storage_class); }) {} + template + explicit Like(fcpp::optional_t T::* fn, R value) + : QueryPredicate(fn, fcpp::optional_t(value), "LIKE", [&](void* v, SqliteStorageClass storage_class) { + return GetOptionalSqlValue(v, storage_class); + }) {} + template explicit Like(int64_t T::* fn, int value) : Like(fn, (int64_t)value) {} @@ -189,6 +250,12 @@ class REFLECTION_EXPORT GreaterThan final : public QueryPredicate { template explicit GreaterThan(double T::* fn, double value) : QueryPredicate(fn, value, ">") {} + + template + explicit GreaterThan(fcpp::optional_t T::* fn, int64_t value) : QueryPredicate(fn, value, ">") {} + + template + explicit GreaterThan(fcpp::optional_t T::* fn, double value) : QueryPredicate(fn, value, ">") {} }; /// A wrapper for a comparison predicate, for which the value of the @@ -203,6 +270,12 @@ class REFLECTION_EXPORT GreaterThanOrEqual final : public QueryPredicate { template explicit GreaterThanOrEqual(double T::* fn, double value) : QueryPredicate(fn, value, ">=") {} + + template + explicit GreaterThanOrEqual(fcpp::optional_t T::* fn, int64_t value) : QueryPredicate(fn, value, ">=") {} + + template + explicit GreaterThanOrEqual(fcpp::optional_t T::* fn, double value) : QueryPredicate(fn, value, ">=") {} }; /// A wrapper for a comparison predicate, for which the value of the @@ -217,6 +290,12 @@ class REFLECTION_EXPORT SmallerThan final : public QueryPredicate { template explicit SmallerThan(double T::* fn, double value) : QueryPredicate(fn, value, "<") {} + + template + explicit SmallerThan(fcpp::optional_t T::* fn, int64_t value) : QueryPredicate(fn, value, "<") {} + + template + explicit SmallerThan(fcpp::optional_t T::* fn, double value) : QueryPredicate(fn, value, "<") {} }; /// A wrapper for a comparison predicate, for which the value of the @@ -231,6 +310,12 @@ class REFLECTION_EXPORT SmallerThanOrEqual final : public QueryPredicate { template explicit SmallerThanOrEqual(double T::* fn, double value) : QueryPredicate(fn, value, "<=") {} + + template + explicit SmallerThanOrEqual(fcpp::optional_t T::* fn, int64_t value) : QueryPredicate(fn, value, "<=") {} + + template + explicit SmallerThanOrEqual(fcpp::optional_t T::* fn, double value) : QueryPredicate(fn, value, "<=") {} }; /// A wrapper of a compound predicate, which combines two other predicates, diff --git a/include/reflection.h b/include/reflection.h index cf12e24..d64da13 100644 --- a/include/reflection.h +++ b/include/reflection.h @@ -33,6 +33,7 @@ #include #include +#include "optional.h" #include "reflection_export.h" /// The storage class in an SQLite column for a given member of a struct, for which reflection is enabled @@ -47,11 +48,12 @@ struct REFLECTION_EXPORT Reflection { /// This holds the metadata of a given struct member class MemberMetadata { public: - MemberMetadata(const std::string& _name, SqliteStorageClass _storage_class, size_t _offset) + MemberMetadata(const std::string& _name, SqliteStorageClass _storage_class, size_t _offset, bool _nullable = false) : name(_name), storage_class(_storage_class), sqlite_column_name(ToSqliteColumnName(_storage_class)), - offset(_offset) {} + offset(_offset), + nullable(_nullable) {} /// The struct variable member name, as defined in the source code std::string name; @@ -66,6 +68,9 @@ struct REFLECTION_EXPORT Reflection { /// The memory offset in bytes of this member from the struct's start, including any padding bits size_t offset; + /// Whether this member is represented by optional_t and may carry SQL NULL + bool nullable; + private: /// Helper for conversion between member storage class and SQLite column name static const char* ToSqliteColumnName(const SqliteStorageClass storage_class) { @@ -113,7 +118,9 @@ size_t OffsetFromStart(R T::* fn) { #define CAT(A, B) CAT_NOEXPAND(A, B) #define DEFINE_MEMBER(R, T) \ - reflectable.member_metadata.push_back(Reflection::MemberMetadata(STR(R), T, offsetof(struct REFLECTABLE, R))); + reflectable.member_metadata.push_back(Reflection::MemberMetadata(STR(R), T, offsetof(struct REFLECTABLE, R), false)); +#define DEFINE_MEMBER_NULLABLE(R, T) \ + reflectable.member_metadata.push_back(Reflection::MemberMetadata(STR(R), T, offsetof(struct REFLECTABLE, R), true)); /// A singleton object which holds all reflectable structs, and is guaranteed to be /// instantiated before main.cpp starts @@ -167,6 +174,11 @@ struct REFLECTABLE_DLL_EXPORT REFLECTABLE { #define MEMBER_TEXT(R) MEMBER_DECLARE(std::wstring, R) #define MEMBER_DATETIME(R) MEMBER_DECLARE(sqlite_reflection::TimePoint, R) #define MEMBER_BOOL(R) MEMBER_DECLARE(bool, R) +#define MEMBER_INT_NULLABLE(R) MEMBER_DECLARE(fcpp::optional_t, R) +#define MEMBER_REAL_NULLABLE(R) MEMBER_DECLARE(fcpp::optional_t, R) +#define MEMBER_TEXT_NULLABLE(R) MEMBER_DECLARE(fcpp::optional_t, R) +#define MEMBER_DATETIME_NULLABLE(R) MEMBER_DECLARE(fcpp::optional_t, R) +#define MEMBER_BOOL_NULLABLE(R) MEMBER_DECLARE(fcpp::optional_t, R) #define FUNC(SIGNATURE) FIELDS #undef MEMBER_DECLARE @@ -175,6 +187,11 @@ struct REFLECTABLE_DLL_EXPORT REFLECTABLE { #undef MEMBER_TEXT #undef MEMBER_DATETIME #undef MEMBER_BOOL +#undef MEMBER_INT_NULLABLE +#undef MEMBER_REAL_NULLABLE +#undef MEMBER_TEXT_NULLABLE +#undef MEMBER_DATETIME_NULLABLE +#undef MEMBER_BOOL_NULLABLE #undef FUNC int64_t id; @@ -184,6 +201,11 @@ struct REFLECTABLE_DLL_EXPORT REFLECTABLE { #define MEMBER_TEXT(R) #define MEMBER_DATETIME(R) #define MEMBER_BOOL(R) +#define MEMBER_INT_NULLABLE(R) +#define MEMBER_REAL_NULLABLE(R) +#define MEMBER_TEXT_NULLABLE(R) +#define MEMBER_DATETIME_NULLABLE(R) +#define MEMBER_BOOL_NULLABLE(R) #define FUNC(SIGNATURE) SIGNATURE; FIELDS #undef MEMBER_INT @@ -191,6 +213,11 @@ struct REFLECTABLE_DLL_EXPORT REFLECTABLE { #undef MEMBER_TEXT #undef MEMBER_DATETIME #undef MEMBER_BOOL +#undef MEMBER_INT_NULLABLE +#undef MEMBER_REAL_NULLABLE +#undef MEMBER_TEXT_NULLABLE +#undef MEMBER_DATETIME_NULLABLE +#undef MEMBER_BOOL_NULLABLE #undef FUNC }; @@ -232,6 +259,11 @@ static std::string CAT(Register, REFLECTABLE)() { #define MEMBER_TEXT(R) DEFINE_MEMBER(R, SqliteStorageClass::kText) #define MEMBER_DATETIME(R) DEFINE_MEMBER(R, SqliteStorageClass::kDateTime) #define MEMBER_BOOL(R) DEFINE_MEMBER(R, SqliteStorageClass::kBool) +#define MEMBER_INT_NULLABLE(R) DEFINE_MEMBER_NULLABLE(R, SqliteStorageClass::kInt) +#define MEMBER_REAL_NULLABLE(R) DEFINE_MEMBER_NULLABLE(R, SqliteStorageClass::kReal) +#define MEMBER_TEXT_NULLABLE(R) DEFINE_MEMBER_NULLABLE(R, SqliteStorageClass::kText) +#define MEMBER_DATETIME_NULLABLE(R) DEFINE_MEMBER_NULLABLE(R, SqliteStorageClass::kDateTime) +#define MEMBER_BOOL_NULLABLE(R) DEFINE_MEMBER_NULLABLE(R, SqliteStorageClass::kBool) #define FUNC(SIGNATURE) FIELDS #undef MEMBER_INT @@ -239,6 +271,11 @@ static std::string CAT(Register, REFLECTABLE)() { #undef MEMBER_TEXT #undef MEMBER_DATETIME #undef MEMBER_BOOL +#undef MEMBER_INT_NULLABLE +#undef MEMBER_REAL_NULLABLE +#undef MEMBER_TEXT_NULLABLE +#undef MEMBER_DATETIME_NULLABLE +#undef MEMBER_BOOL_NULLABLE #undef FUNC } return name; diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 29f85bf..069e0ed 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -25,6 +25,8 @@ source_group("internal" FILES ${HEADERS_INTERNAL}) # Set Properties->General->Configuration Type to Dynamic Library (.dll/.so/.dylib) add_library(${LIBNAME} SHARED ${HEADERS} ${HEADERS_INTERNAL} ${SOURCES}) +target_link_libraries(${LIBNAME} PUBLIC fcpp) +target_include_directories(${LIBNAME} PUBLIC ${functional_cpp_SOURCE_DIR}/include) if(CMAKE_HOST_UNIX AND NOT CMAKE_HOST_APPLE) target_link_libraries(${LIBNAME} PUBLIC tbb dl) diff --git a/src/queries.cc b/src/queries.cc index 5148b67..adeb74a 100644 --- a/src/queries.cc +++ b/src/queries.cc @@ -42,6 +42,10 @@ std::string Placeholders(size_t count) { } void BindValue(sqlite3_stmt* stmt, int index, const SqlValue& value) { + if (value.is_null) { + sqlite3_bind_null(stmt, index); + return; + } switch (value.storage_class) { case SqliteStorageClass::kInt: sqlite3_bind_int64(stmt, index, value.int_value); @@ -137,32 +141,79 @@ std::vector ExecutionQuery::GetValues(void* p, bool skip_id) const { const auto current_storage_class = members[j].storage_class; SqlValue value; value.storage_class = current_storage_class; + value.is_null = members[j].nullable; + const auto address = GetMemberAddress(p, record_, j); switch (current_storage_class) { case SqliteStorageClass::kInt: { - value.int_value = *reinterpret_cast(GetMemberAddress(p, record_, j)); + if (members[j].nullable) { + auto& optional_value = *reinterpret_cast*>(address); + if (optional_value.has_value()) { + value.is_null = false; + value.int_value = optional_value.value(); + } + } else { + value.is_null = false; + value.int_value = *reinterpret_cast(address); + } break; } case SqliteStorageClass::kBool: { - value.bool_value = *reinterpret_cast(GetMemberAddress(p, record_, j)); + if (members[j].nullable) { + auto& optional_value = *reinterpret_cast*>(address); + if (optional_value.has_value()) { + value.is_null = false; + value.bool_value = optional_value.value(); + } + } else { + value.is_null = false; + value.bool_value = *reinterpret_cast(address); + } break; } case SqliteStorageClass::kReal: { - value.real_value = *reinterpret_cast(GetMemberAddress(p, record_, j)); + if (members[j].nullable) { + auto& optional_value = *reinterpret_cast*>(address); + if (optional_value.has_value()) { + value.is_null = false; + value.real_value = optional_value.value(); + } + } else { + value.is_null = false; + value.real_value = *reinterpret_cast(address); + } break; } case SqliteStorageClass::kText: { - auto& text = *reinterpret_cast(GetMemberAddress(p, record_, j)); - value.text_value = StringUtilities::ToUtf8(text); + if (members[j].nullable) { + auto& optional_value = *reinterpret_cast*>(address); + if (optional_value.has_value()) { + value.is_null = false; + value.text_value = StringUtilities::ToUtf8(optional_value.value()); + } + } else { + value.is_null = false; + auto& text = *reinterpret_cast(address); + value.text_value = StringUtilities::ToUtf8(text); + } break; } case SqliteStorageClass::kDateTime: { - auto& time_point = *reinterpret_cast(GetMemberAddress(p, record_, j)); - value.text_value = StringUtilities::ToUtf8(time_point.SystemTime()); + if (members[j].nullable) { + auto& optional_value = *reinterpret_cast*>(address); + if (optional_value.has_value()) { + value.is_null = false; + value.text_value = StringUtilities::ToUtf8(optional_value.value().SystemTime()); + } + } else { + value.is_null = false; + auto& time_point = *reinterpret_cast(address); + value.text_value = StringUtilities::ToUtf8(time_point.SystemTime()); + } break; } @@ -196,7 +247,8 @@ std::string CreateTableQuery::CustomizedColumnName(size_t index) const { // AUTOINCREMENT guarantees that ids are never reused, even after the row with the // highest id is deleted - return is_id ? name + " PRIMARY KEY AUTOINCREMENT" : name; + return is_id ? name + " PRIMARY KEY AUTOINCREMENT" + : (record_.member_metadata[index].nullable ? name : name + " NOT NULL"); } DeleteQuery::DeleteQuery(sqlite3* db, const Reflection& record, const QueryPredicateBase* predicate) @@ -312,39 +364,79 @@ void FetchRecordsQuery::HydrateCurrentRow(void* p, const Reflection& record) con // here so a NULL or BLOB value (reachable via UnsafeSql or a foreign database file, // even for a column declared as a different storage class) is never fed to the // wrong typed accessor. + const auto& metadata = record.member_metadata[j]; const int col_type = sqlite3_column_type(stmt_, col); + if (metadata.nullable && col_type == SQLITE_NULL) { + const auto address = GetMemberAddress(p, record, j); + switch (metadata.storage_class) { + case SqliteStorageClass::kInt: + *reinterpret_cast*>(address) = fcpp::optional_t(); + break; + case SqliteStorageClass::kBool: + *reinterpret_cast*>(address) = fcpp::optional_t(); + break; + case SqliteStorageClass::kReal: + *reinterpret_cast*>(address) = fcpp::optional_t(); + break; + case SqliteStorageClass::kText: + *reinterpret_cast*>(address) = fcpp::optional_t(); + break; + case SqliteStorageClass::kDateTime: + *reinterpret_cast*>(address) = fcpp::optional_t(); + break; + } + continue; + } if (col_type == SQLITE_NULL || col_type == SQLITE_BLOB) { continue; } - const auto current_storage_class = record.member_metadata[j].storage_class; + const auto current_storage_class = metadata.storage_class; + const auto address = GetMemberAddress(p, record, j); switch (current_storage_class) { case SqliteStorageClass::kInt: { - auto& v = *reinterpret_cast(GetMemberAddress(p, record, j)); - v = sqlite3_column_int64(stmt_, col); + if (metadata.nullable) { + *reinterpret_cast*>(address) = sqlite3_column_int64(stmt_, col); + } else { + auto& v = *reinterpret_cast(address); + v = sqlite3_column_int64(stmt_, col); + } break; } case SqliteStorageClass::kBool: { - auto& v = *reinterpret_cast(GetMemberAddress(p, record, j)); - v = sqlite3_column_int64(stmt_, col) == 1; + if (metadata.nullable) { + *reinterpret_cast*>(address) = sqlite3_column_int64(stmt_, col) == 1; + } else { + auto& v = *reinterpret_cast(address); + v = sqlite3_column_int64(stmt_, col) == 1; + } break; } case SqliteStorageClass::kReal: { - auto& v = *reinterpret_cast(GetMemberAddress(p, record, j)); - v = sqlite3_column_double(stmt_, col); + if (metadata.nullable) { + *reinterpret_cast*>(address) = sqlite3_column_double(stmt_, col); + } else { + auto& v = *reinterpret_cast(address); + v = sqlite3_column_double(stmt_, col); + } break; } case SqliteStorageClass::kText: { const auto byte_count = sqlite3_column_bytes(stmt_, col); - if (byte_count == 0) { + if (!metadata.nullable && byte_count == 0) { continue; } const auto content = reinterpret_cast(sqlite3_column_text(stmt_, col)); - auto& v = *reinterpret_cast(GetMemberAddress(p, record, j)); - v = StringUtilities::FromUtf8(content, byte_count); + const auto value = StringUtilities::FromUtf8(content, byte_count); + if (metadata.nullable) { + *reinterpret_cast*>(address) = value; + } else { + auto& v = *reinterpret_cast(address); + v = value; + } break; } @@ -354,8 +446,13 @@ void FetchRecordsQuery::HydrateCurrentRow(void* p, const Reflection& record) con continue; } const auto content = reinterpret_cast(sqlite3_column_text(stmt_, col)); - auto& v = *reinterpret_cast(GetMemberAddress(p, record, j)); - v = TimePoint::FromSystemTime(StringUtilities::FromUtf8(content, byte_count)); + const auto value = TimePoint::FromSystemTime(StringUtilities::FromUtf8(content, byte_count)); + if (metadata.nullable) { + *reinterpret_cast*>(address) = value; + } else { + auto& v = *reinterpret_cast(address); + v = value; + } break; } diff --git a/src/query_predicates.cc b/src/query_predicates.cc index 3ab46f9..6e6768c 100644 --- a/src/query_predicates.cc +++ b/src/query_predicates.cc @@ -47,12 +47,25 @@ std::string EscapeLikeWildcards(const std::string& value) { } } // namespace -SqlValue::SqlValue() : storage_class(SqliteStorageClass::kText), int_value(0), bool_value(false), real_value(0.0) {} +SqlValue::SqlValue() + : storage_class(SqliteStorageClass::kText), int_value(0), bool_value(false), real_value(0.0), is_null(false) {} QueryPredicateBase* QueryPredicate::Clone() const { return new QueryPredicate(symbol_, member_name_, value_); } +std::string NullPredicate::Evaluate() const { + return member_name_ + space + symbol_; +} + +std::vector NullPredicate::Bindings() const { + return {}; +} + +QueryPredicateBase* NullPredicate::Clone() const { + return new NullPredicate(symbol_, member_name_); +} + std::string EmptyPredicate::Evaluate() const { return ""; } @@ -120,6 +133,23 @@ SqlValue QueryPredicate::GetSqlValue(void* v, SqliteStorageClass storage_class) } } +SqlValue QueryPredicate::GetOptionalSqlValue(void* v, SqliteStorageClass storage_class) const { + switch (storage_class) { + case SqliteStorageClass::kInt: + return GetSqlValue(&reinterpret_cast*>(v)->value(), storage_class); + case SqliteStorageClass::kBool: + return GetSqlValue(&reinterpret_cast*>(v)->value(), storage_class); + case SqliteStorageClass::kReal: + return GetSqlValue(&reinterpret_cast*>(v)->value(), storage_class); + case SqliteStorageClass::kText: + return GetSqlValue(&reinterpret_cast*>(v)->value(), storage_class); + case SqliteStorageClass::kDateTime: + return GetSqlValue(&reinterpret_cast*>(v)->value(), storage_class); + default: + throw std::domain_error("Blob cannot be compared"); + } +} + SqlValue Like::GetSqlValue(void* v, SqliteStorageClass storage_class) const { auto value = QueryPredicate::GetSqlValue(v, storage_class); switch (storage_class) { diff --git a/tests/database_test.cc b/tests/database_test.cc index 29cc875..a29b004 100644 --- a/tests/database_test.cc +++ b/tests/database_test.cc @@ -24,12 +24,19 @@ #include +#include +#include #include #include +#if __cplusplus >= 201703L +#include +#endif #include #include "company.h" #include "id_only_record.h" +#include "internal/sqlite3.h" +#include "nullable_record.h" #include "person.h" #include "pet.h" @@ -66,6 +73,10 @@ static_assert(!CanSaveThroughConst::value, "Save must not be invocable through a const Database - write methods are non-const"); static_assert(CanFetchAllThroughConst::value, "FetchAll must remain invocable through a const Database - read methods are const"); +#if __cplusplus >= 201703L +static_assert(std::is_same>::value, + "functional_cpp optional_t must alias std::optional in C++17 and later"); +#endif class DatabaseTest : public ::testing::Test { void SetUp() override { @@ -90,6 +101,99 @@ TEST_F(DatabaseTest, ReadOnlyHandleCanStillFetch) { EXPECT_EQ(p.first_name, all[0].first_name); } + +TEST_F(DatabaseTest, SchemaMarksOnlyNonNullableFieldsNotNull) { + Database::Finalize(); + const std::string path = "/tmp/sqlite_reflection_nullable_schema.db"; + std::remove(path.c_str()); + Database::Initialize(path); + Database::Finalize(); + + sqlite3* db = nullptr; + ASSERT_EQ(SQLITE_OK, sqlite3_open(path.c_str(), &db)); + sqlite3_stmt* stmt = nullptr; + ASSERT_EQ(SQLITE_OK, sqlite3_prepare_v2(db, "PRAGMA table_info(NullableRecord);", -1, &stmt, nullptr)); + + std::map not_null_by_column; + while (sqlite3_step(stmt) == SQLITE_ROW) { + const auto name = reinterpret_cast(sqlite3_column_text(stmt, 1)); + not_null_by_column[name] = sqlite3_column_int(stmt, 3); + } + sqlite3_finalize(stmt); + sqlite3_close(db); + std::remove(path.c_str()); + + EXPECT_EQ(0, not_null_by_column["optional_int"]); + EXPECT_EQ(0, not_null_by_column["optional_real"]); + EXPECT_EQ(0, not_null_by_column["optional_text"]); + EXPECT_EQ(0, not_null_by_column["optional_time"]); + EXPECT_EQ(0, not_null_by_column["optional_bool"]); + EXPECT_EQ(1, not_null_by_column["required_int"]); +} + +TEST_F(DatabaseTest, NullableFieldsRoundTripAndDistinguishNullFromDefaults) { + const auto db = Database::Instance(); + + NullableRecord unset; + unset.required_int = 7; + unset.id = 1; + db->Save(unset); + + NullableRecord set; + set.optional_int = int64_t{0}; + set.optional_real = 0.0; + set.optional_text = std::wstring(); + set.optional_time = TimePoint(int64_t{0}); + set.optional_bool = false; + set.required_int = 8; + set.id = 2; + db->Save(set); + + const auto fetched = db->FetchAll(); + ASSERT_EQ(2, fetched.size()); + EXPECT_FALSE(fetched[0].optional_int.has_value()); + EXPECT_FALSE(fetched[0].optional_real.has_value()); + EXPECT_FALSE(fetched[0].optional_text.has_value()); + EXPECT_FALSE(fetched[0].optional_time.has_value()); + EXPECT_FALSE(fetched[0].optional_bool.has_value()); + + ASSERT_TRUE(fetched[1].optional_int.has_value()); + EXPECT_EQ(0, fetched[1].optional_int.value()); + ASSERT_TRUE(fetched[1].optional_real.has_value()); + EXPECT_EQ(0.0, fetched[1].optional_real.value()); + ASSERT_TRUE(fetched[1].optional_text.has_value()); + EXPECT_EQ(std::wstring(), fetched[1].optional_text.value()); + ASSERT_TRUE(fetched[1].optional_time.has_value()); + EXPECT_EQ(TimePoint(int64_t{0}).SystemTime(), fetched[1].optional_time.value().SystemTime()); + ASSERT_TRUE(fetched[1].optional_bool.has_value()); + EXPECT_FALSE(fetched[1].optional_bool.value()); +} + +TEST_F(DatabaseTest, NullPredicatesSelectExpectedRows) { + const auto db = Database::Instance(); + + NullableRecord unset; + unset.required_int = 7; + unset.id = 1; + db->Save(unset); + + NullableRecord set; + set.optional_text = L"value"; + set.required_int = 8; + set.id = 2; + db->Save(set); + + const IsNull null_text(&NullableRecord::optional_text); + const auto null_rows = db->Fetch(&null_text); + ASSERT_EQ(1, null_rows.size()); + EXPECT_EQ(1, null_rows[0].id); + + const IsNotNull not_null_text(&NullableRecord::optional_text); + const auto not_null_rows = db->Fetch(¬_null_text); + ASSERT_EQ(1, not_null_rows.size()); + EXPECT_EQ(2, not_null_rows[0].id); +} + TEST_F(DatabaseTest, Initialization) { const auto db = Database::Instance(); diff --git a/tests/nullable_record.h b/tests/nullable_record.h new file mode 100644 index 0000000..892b505 --- /dev/null +++ b/tests/nullable_record.h @@ -0,0 +1,33 @@ +// MIT License +// +// Copyright (c) 2026 Ioannis Kaliakatsos +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +#pragma once + +#define REFLECTABLE NullableRecord +#define FIELDS \ + MEMBER_INT_NULLABLE(optional_int) \ + MEMBER_REAL_NULLABLE(optional_real) \ + MEMBER_TEXT_NULLABLE(optional_text) \ + MEMBER_DATETIME_NULLABLE(optional_time) \ + MEMBER_BOOL_NULLABLE(optional_bool) \ + MEMBER_INT(required_int) +#include "reflection.h" diff --git a/tests/query_predicates_test.cc b/tests/query_predicates_test.cc index 079152f..1010243 100644 --- a/tests/query_predicates_test.cc +++ b/tests/query_predicates_test.cc @@ -27,6 +27,7 @@ #include #include +#include "nullable_record.h" #include "person.h" #include "pet.h" @@ -263,3 +264,23 @@ TEST(QueryPredicatesTest, PredicateConstructionThrowsWhenNoMemberMatches) { EXPECT_THROW(Equal(&MismatchedRecord::value, 42), std::runtime_error); } + + +TEST(QueryPredicatesTest, NullableValuePredicatesUseContainedValue) { + const Equal condition(&NullableRecord::optional_int, int64_t{42}); + EXPECT_EQ(0, strcmp(condition.Evaluate().data(), "optional_int = ?")); + const auto bindings = condition.Bindings(); + ASSERT_EQ(1, bindings.size()); + EXPECT_EQ(SqliteStorageClass::kInt, bindings[0].storage_class); + EXPECT_EQ(42, bindings[0].int_value); +} + +TEST(QueryPredicatesTest, NullPredicatesHaveNoBindings) { + const IsNull is_null(&NullableRecord::optional_text); + EXPECT_EQ(0, strcmp(is_null.Evaluate().data(), "optional_text IS NULL")); + EXPECT_TRUE(is_null.Bindings().empty()); + + const IsNotNull is_not_null(&NullableRecord::optional_text); + EXPECT_EQ(0, strcmp(is_not_null.Evaluate().data(), "optional_text IS NOT NULL")); + EXPECT_TRUE(is_not_null.Bindings().empty()); +} From 6296389c75bfdb413b454957b20cef5688bb0b3c Mon Sep 17 00:00:00 2001 From: jkalias Date: Sat, 18 Jul 2026 23:43:33 +0200 Subject: [PATCH 2/5] Add test include path for SQLite internals --- tests/CMakeLists.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index c9cdce3..1bf5851 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -6,6 +6,7 @@ include(GoogleTest) # Properties->C/C++->General->Additional Include Directories include_directories ("${PROJECT_SOURCE_DIR}/include") +include_directories ("${PROJECT_SOURCE_DIR}/src") include_directories ("./include") # Collect test sources into the variable TEST_SOURCES From aefc4f9482186bc8d7f6a7ed4ac18ba6e91bb168 Mon Sep 17 00:00:00 2001 From: jkalias Date: Sat, 18 Jul 2026 23:50:13 +0200 Subject: [PATCH 3/5] Use portable temp path in schema test --- tests/database_test.cc | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/database_test.cc b/tests/database_test.cc index a29b004..e2f48f0 100644 --- a/tests/database_test.cc +++ b/tests/database_test.cc @@ -25,6 +25,7 @@ #include #include +#include #include #include #include @@ -104,7 +105,14 @@ TEST_F(DatabaseTest, ReadOnlyHandleCanStillFetch) { TEST_F(DatabaseTest, SchemaMarksOnlyNonNullableFieldsNotNull) { Database::Finalize(); - const std::string path = "/tmp/sqlite_reflection_nullable_schema.db"; + const char* temp_dir = std::getenv("RUNNER_TEMP"); + if (temp_dir == nullptr) { + temp_dir = std::getenv("TMPDIR"); + } + if (temp_dir == nullptr) { + temp_dir = std::getenv("TEMP"); + } + const std::string path = std::string(temp_dir == nullptr ? "." : temp_dir) + "/sqlite_reflection_nullable_schema.db"; std::remove(path.c_str()); Database::Initialize(path); Database::Finalize(); From 232bdf0af6d967c8883fe7d99bff4893a70c6188 Mon Sep 17 00:00:00 2001 From: jkalias Date: Sun, 19 Jul 2026 00:13:28 +0200 Subject: [PATCH 4/5] Address nullable review feedback --- README.md | 2 +- include/query_predicates.h | 18 +++++++++++++ src/query_predicates.cc | 45 ++++++++++++++++++++++++-------- tests/CMakeLists.txt | 1 - tests/database_test.cc | 47 ++++++++-------------------------- tests/query_predicates_test.cc | 5 ++++ 6 files changed, 69 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index 8a23fc3..9f73e27 100644 --- a/README.md +++ b/README.md @@ -140,7 +140,7 @@ Supported field macros: | `fcpp::optional_t` | `MEMBER_DATETIME_NULLABLE(name)` | | member function declaration | `FUNC(signature)` | -Nullable macros store SQL `NULL` as an empty optional and bind an empty optional back as SQL `NULL`; present optional values round-trip using the same storage class as their non-nullable counterparts, so `NULL`, an empty string, and numeric/boolean zero remain distinguishable after fetch. The optional type comes from [`functional_cpp`](https://github.com/jkalias/functional_cpp): under C++17 and later `fcpp::optional_t` aliases `std::optional`, while C++11 builds use the library fallback. Use `IsNull(&T::field)` and `IsNotNull(&T::field)` predicates for null checks. Value predicates such as `Equal(&T::nullable_field, value)` compare against a present contained value; pass the contained `T` value, not an optional. +Nullable macros store SQL `NULL` as an empty optional and bind an empty optional back as SQL `NULL`; present optional values round-trip using the same storage class as their non-nullable counterparts, so `NULL`, an empty string, and numeric/boolean zero remain distinguishable after fetch. The optional type comes from [`functional_cpp`](https://github.com/jkalias/functional_cpp): under C++17 and later `fcpp::optional_t` aliases `std::optional`, while C++11 builds use the library fallback. Use `IsNull(&T::field)` and `IsNotNull(&T::field)` predicates for null checks. Value predicates such as `Equal(&T::nullable_field, value)` compare against a present contained value; pass the contained `T` value, not an optional. If an unset optional is passed as the comparison value, the predicate constructor throws `std::invalid_argument`; use `IsNull` for that query instead. **Schema nullability caveat.** Newly created tables now declare non-nullable reflected fields as `NOT NULL` and omit `NOT NULL` for nullable fields. Because initialization uses `CREATE TABLE IF NOT EXISTS`, existing database files keep their previous column definitions until you recreate or migrate those tables, just like the `AUTOINCREMENT` caveat below. diff --git a/include/query_predicates.h b/include/query_predicates.h index 630fcb3..05354b5 100644 --- a/include/query_predicates.h +++ b/include/query_predicates.h @@ -110,6 +110,12 @@ class REFLECTION_EXPORT QueryPredicate : public QueryPredicateBase { return GetOptionalSqlValue(v, storage_class); }) {} + template + QueryPredicate(fcpp::optional_t T::* fn, fcpp::optional_t value, const std::string& symbol) + : QueryPredicate(fn, value, symbol, [&](void* v, SqliteStorageClass storage_class) { + return GetOptionalSqlValue(v, storage_class); + }) {} + QueryPredicate(const std::string& symbol, const std::string& member_name, const SqlValue& value) : symbol_(symbol), member_name_(member_name), value_(value) {} @@ -189,6 +195,9 @@ class REFLECTION_EXPORT Equal final : public QueryPredicate { template explicit Equal(fcpp::optional_t T::* fn, R value) : QueryPredicate(fn, value, "=") {} + template + explicit Equal(fcpp::optional_t T::* fn, fcpp::optional_t value) : QueryPredicate(fn, value, "=") {} + template explicit Equal(int64_t T::* fn, int value) : Equal(fn, (int64_t)value) {} @@ -206,6 +215,9 @@ class REFLECTION_EXPORT Unequal final : public QueryPredicate { template explicit Unequal(fcpp::optional_t T::* fn, R value) : QueryPredicate(fn, value, "!=") {} + template + explicit Unequal(fcpp::optional_t T::* fn, fcpp::optional_t value) : QueryPredicate(fn, value, "!=") {} + template explicit Unequal(int64_t T::* fn, int value) : Unequal(fn, (int64_t)value) {} @@ -228,6 +240,12 @@ class REFLECTION_EXPORT Like final : public QueryPredicate { return GetOptionalSqlValue(v, storage_class); }) {} + template + explicit Like(fcpp::optional_t T::* fn, fcpp::optional_t value) + : QueryPredicate(fn, value, "LIKE", [&](void* v, SqliteStorageClass storage_class) { + return GetOptionalSqlValue(v, storage_class); + }) {} + template explicit Like(int64_t T::* fn, int value) : Like(fn, (int64_t)value) {} diff --git a/src/query_predicates.cc b/src/query_predicates.cc index 6e6768c..e0e74ea 100644 --- a/src/query_predicates.cc +++ b/src/query_predicates.cc @@ -135,16 +135,41 @@ SqlValue QueryPredicate::GetSqlValue(void* v, SqliteStorageClass storage_class) SqlValue QueryPredicate::GetOptionalSqlValue(void* v, SqliteStorageClass storage_class) const { switch (storage_class) { - case SqliteStorageClass::kInt: - return GetSqlValue(&reinterpret_cast*>(v)->value(), storage_class); - case SqliteStorageClass::kBool: - return GetSqlValue(&reinterpret_cast*>(v)->value(), storage_class); - case SqliteStorageClass::kReal: - return GetSqlValue(&reinterpret_cast*>(v)->value(), storage_class); - case SqliteStorageClass::kText: - return GetSqlValue(&reinterpret_cast*>(v)->value(), storage_class); - case SqliteStorageClass::kDateTime: - return GetSqlValue(&reinterpret_cast*>(v)->value(), storage_class); + case SqliteStorageClass::kInt: { + auto& value = *reinterpret_cast*>(v); + if (!value.has_value()) { + throw std::invalid_argument("Unset optional cannot be used in a value predicate; use IsNull instead"); + } + return GetSqlValue(&value.value(), storage_class); + } + case SqliteStorageClass::kBool: { + auto& value = *reinterpret_cast*>(v); + if (!value.has_value()) { + throw std::invalid_argument("Unset optional cannot be used in a value predicate; use IsNull instead"); + } + return GetSqlValue(&value.value(), storage_class); + } + case SqliteStorageClass::kReal: { + auto& value = *reinterpret_cast*>(v); + if (!value.has_value()) { + throw std::invalid_argument("Unset optional cannot be used in a value predicate; use IsNull instead"); + } + return GetSqlValue(&value.value(), storage_class); + } + case SqliteStorageClass::kText: { + auto& value = *reinterpret_cast*>(v); + if (!value.has_value()) { + throw std::invalid_argument("Unset optional cannot be used in a value predicate; use IsNull instead"); + } + return GetSqlValue(&value.value(), storage_class); + } + case SqliteStorageClass::kDateTime: { + auto& value = *reinterpret_cast*>(v); + if (!value.has_value()) { + throw std::invalid_argument("Unset optional cannot be used in a value predicate; use IsNull instead"); + } + return GetSqlValue(&value.value(), storage_class); + } default: throw std::domain_error("Blob cannot be compared"); } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 1bf5851..c9cdce3 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -6,7 +6,6 @@ include(GoogleTest) # Properties->C/C++->General->Additional Include Directories include_directories ("${PROJECT_SOURCE_DIR}/include") -include_directories ("${PROJECT_SOURCE_DIR}/src") include_directories ("./include") # Collect test sources into the variable TEST_SOURCES diff --git a/tests/database_test.cc b/tests/database_test.cc index e2f48f0..54f999d 100644 --- a/tests/database_test.cc +++ b/tests/database_test.cc @@ -24,9 +24,6 @@ #include -#include -#include -#include #include #include #if __cplusplus >= 201703L @@ -36,7 +33,6 @@ #include "company.h" #include "id_only_record.h" -#include "internal/sqlite3.h" #include "nullable_record.h" #include "person.h" #include "pet.h" @@ -104,39 +100,16 @@ TEST_F(DatabaseTest, ReadOnlyHandleCanStillFetch) { TEST_F(DatabaseTest, SchemaMarksOnlyNonNullableFieldsNotNull) { - Database::Finalize(); - const char* temp_dir = std::getenv("RUNNER_TEMP"); - if (temp_dir == nullptr) { - temp_dir = std::getenv("TMPDIR"); - } - if (temp_dir == nullptr) { - temp_dir = std::getenv("TEMP"); - } - const std::string path = std::string(temp_dir == nullptr ? "." : temp_dir) + "/sqlite_reflection_nullable_schema.db"; - std::remove(path.c_str()); - Database::Initialize(path); - Database::Finalize(); - - sqlite3* db = nullptr; - ASSERT_EQ(SQLITE_OK, sqlite3_open(path.c_str(), &db)); - sqlite3_stmt* stmt = nullptr; - ASSERT_EQ(SQLITE_OK, sqlite3_prepare_v2(db, "PRAGMA table_info(NullableRecord);", -1, &stmt, nullptr)); - - std::map not_null_by_column; - while (sqlite3_step(stmt) == SQLITE_ROW) { - const auto name = reinterpret_cast(sqlite3_column_text(stmt, 1)); - not_null_by_column[name] = sqlite3_column_int(stmt, 3); - } - sqlite3_finalize(stmt); - sqlite3_close(db); - std::remove(path.c_str()); - - EXPECT_EQ(0, not_null_by_column["optional_int"]); - EXPECT_EQ(0, not_null_by_column["optional_real"]); - EXPECT_EQ(0, not_null_by_column["optional_text"]); - EXPECT_EQ(0, not_null_by_column["optional_time"]); - EXPECT_EQ(0, not_null_by_column["optional_bool"]); - EXPECT_EQ(1, not_null_by_column["required_int"]); + const auto db = Database::Instance(); + + EXPECT_THROW(db->UnsafeSql("INSERT INTO NullableRecord (id, optional_int, required_int) VALUES (100, NULL, NULL);"), + std::domain_error); + EXPECT_NO_THROW( + db->UnsafeSql("INSERT INTO NullableRecord (id, optional_int, required_int) VALUES (101, NULL, 1);")); + + const auto row = db->Fetch(101); + EXPECT_FALSE(row.optional_int.has_value()); + EXPECT_EQ(1, row.required_int); } TEST_F(DatabaseTest, NullableFieldsRoundTripAndDistinguishNullFromDefaults) { diff --git a/tests/query_predicates_test.cc b/tests/query_predicates_test.cc index 1010243..c4e57c6 100644 --- a/tests/query_predicates_test.cc +++ b/tests/query_predicates_test.cc @@ -275,6 +275,11 @@ TEST(QueryPredicatesTest, NullableValuePredicatesUseContainedValue) { EXPECT_EQ(42, bindings[0].int_value); } +TEST(QueryPredicatesTest, UnsetOptionalValuePredicateThrows) { + const fcpp::optional_t unset; + EXPECT_THROW(Equal(&NullableRecord::optional_int, unset), std::invalid_argument); +} + TEST(QueryPredicatesTest, NullPredicatesHaveNoBindings) { const IsNull is_null(&NullableRecord::optional_text); EXPECT_EQ(0, strcmp(is_null.Evaluate().data(), "optional_text IS NULL")); From d1bb7f6198f560851791d5eb4fc36b3c459f0042 Mon Sep 17 00:00:00 2001 From: jkalias Date: Sun, 19 Jul 2026 00:18:43 +0200 Subject: [PATCH 5/5] Fix legacy null hydration tests with NOT NULL schema --- tests/database_test.cc | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/database_test.cc b/tests/database_test.cc index 54f999d..a2a4aeb 100644 --- a/tests/database_test.cc +++ b/tests/database_test.cc @@ -62,6 +62,12 @@ struct CanFetchAllThroughConst : std::false_type {}; template struct CanFetchAllThroughConst().FetchAll())>::type> : std::true_type {}; + +void RecreateCompanyTableAllowingNulls(const std::shared_ptr& db) { + db->UnsafeSql("DROP TABLE Company;"); + db->UnsafeSql("CREATE TABLE Company (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, age INTEGER, " + "address TEXT, salary REAL);"); +} } // namespace // The const-correctness contract (#26): const means read-only. A handle to a const Database @@ -837,6 +843,7 @@ TEST_F(DatabaseTest, FetchPreservesHighPrecisionDoubleValues) { TEST_F(DatabaseTest, FetchPreservesNullAndEmptyStringSkipSemantics) { const auto db = Database::Instance(); + RecreateCompanyTableAllowingNulls(db); // Columns omitted from a raw INSERT are stored as SQL NULL. Direct hydration must skip // assignment for a NULL column, and must also skip assignment for a TEXT column holding @@ -865,6 +872,7 @@ TEST_F(DatabaseTest, FetchPreservesNullAndEmptyStringSkipSemantics) { TEST_F(DatabaseTest, FetchSkipsBlobColumnsWithoutThrowing) { const auto db = Database::Instance(); + RecreateCompanyTableAllowingNulls(db); // A BLOB value (reachable via raw SQL despite the column's declared affinity - here an // invalid UTF-8 byte) must be skipped exactly like a NULL, not fed into the typed