From e2e17737588f964209e664730f20152f2e56273a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 07:14:13 +0000 Subject: [PATCH 1/9] Pull in functional_cpp for its C++11-compatible optional (#2) fcpp::optional_t is the nullable-field representation: it aliases std::optional under C++17 and later and falls back to an equivalent C++11 implementation otherwise. Consumed header-only via FetchContent, pinned to tag 1.1.1; SOURCE_SUBDIR points at the header directory (which has no CMakeLists.txt) so only the sources are downloaded, without building functional_cpp's own library and test targets. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01NUt3c1wdseRtRSSXfg3MCK --- CMakeLists.txt | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/CMakeLists.txt b/CMakeLists.txt index c480b8b..1143abb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -24,6 +24,20 @@ FetchContent_Declare( ) FetchContent_MakeAvailable(googletest) +# functional_cpp provides fcpp::optional_t, the nullable-field representation +# (std::optional under C++17 and later, an equivalent C++11 fallback otherwise). +# It is consumed header-only: SOURCE_SUBDIR points at the header directory, which +# contains no CMakeLists.txt, so FetchContent only downloads the sources without +# building functional_cpp's own library and test targets. +FetchContent_Declare( + functional_cpp + GIT_REPOSITORY https://github.com/jkalias/functional_cpp.git + GIT_TAG 1.1.1 + SOURCE_SUBDIR include +) +FetchContent_MakeAvailable(functional_cpp) +include_directories(${functional_cpp_SOURCE_DIR}/include) + # Set compiler settings based on the current platform if(CMAKE_HOST_UNIX) add_definitions(-w -fpermissive -Wshadow -Wunused -Woverloaded-virtual -Wnon-virtual-dtor -Werror) From 3371a215f7636bce056663f7127da51bf66a04f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 07:14:13 +0000 Subject: [PATCH 2/9] Add MEMBER_*_NULLABLE macros and a nullability flag in member metadata (#2) Each field macro gains a _NULLABLE variant declaring the member as fcpp::optional_t of the underlying type (std::optional under C++17+). MemberMetadata carries an orthogonal nullable flag - storage_class keeps describing the underlying contained type - set via the new DEFINE_MEMBER_NULLABLE during registration, so query generation, binding, and hydration can branch on it. No behavior change yet for existing records: the flag defaults to false everywhere. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01NUt3c1wdseRtRSSXfg3MCK --- include/reflection.h | 47 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 45 insertions(+), 2 deletions(-) diff --git a/include/reflection.h b/include/reflection.h index cf12e24..b8e1042 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,13 @@ 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 +69,11 @@ 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 was declared through a MEMBER_*_NULLABLE macro. A nullable member + /// is an fcpp::optional_t of the underlying storage type (std::optional under C++17 and + /// later); the storage_class above always describes the underlying, contained type + bool nullable; + private: /// Helper for conversion between member storage class and SQLite column name static const char* ToSqliteColumnName(const SqliteStorageClass storage_class) { @@ -115,6 +123,9 @@ size_t OffsetFromStart(R T::* fn) { #define DEFINE_MEMBER(R, T) \ reflectable.member_metadata.push_back(Reflection::MemberMetadata(STR(R), T, offsetof(struct REFLECTABLE, R))); +#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 struct REFLECTION_EXPORT ReflectionRegister { @@ -161,12 +172,19 @@ REFLECTION_EXPORT char* GetMemberAddress(void* p, const Reflection& record, size /// corruption, not a compile or runtime error) unless caught by the static_assert below. struct REFLECTABLE_DLL_EXPORT REFLECTABLE { // member declaration according to the order given in source code + // nullable members are declared as fcpp::optional_t of the underlying type, + // which is std::optional under C++17 and later #define MEMBER_DECLARE(L, R) L R; #define MEMBER_INT(R) MEMBER_DECLARE(int64_t, R) #define MEMBER_REAL(R) MEMBER_DECLARE(double, R) #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 +193,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 +207,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 +219,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 +265,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 +277,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; From b0e8e3a2c9719902044c315a19355487299bd522 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 07:14:13 +0000 Subject: [PATCH 3/9] Round-trip SQL NULL for nullable members across schema, read, write, and predicates (#2, #20) Write side: SqlValue gains an is_null state bound via sqlite3_bind_null; GetValues reads a nullable member through its optional and produces a null SqlValue when it is empty. Read side (the #20 fix): HydrateCurrentRow hydrates a nullable member's SQL NULL into an empty optional instead of skipping it, and a present value into the contained value - an empty TEXT becomes a contained empty string, distinct from NULL, ending the NULL/empty conflation. Non-nullable members keep today's exact skip semantics, including the NULL/BLOB accessor-safety skip. Schema: CreateTableQuery emits NOT NULL for non-nullable columns and omits it for nullable ones (id keeps PRIMARY KEY AUTOINCREMENT, which is implicitly NOT NULL). Only newly created tables are affected, since tables are created with CREATE TABLE IF NOT EXISTS. The pre-existing NULL-skip-semantics test now recreates its table the way a legacy database file would look, which is the very scenario those skip semantics protect. Predicates: new IsNull()/IsNotNull() emit " IS NULL"/"IS NOT NULL" with no bound value and survive Clone() inside And()/Or() compounds. Value predicates on a nullable member compare against the contained value; given an empty optional they throw std::invalid_argument, since "= NULL" never matches in SQL - IsNull()/IsNotNull() is the correct tool there. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01NUt3c1wdseRtRSSXfg3MCK --- include/query_predicates.h | 80 +++++++++++++++++- src/queries.cc | 166 ++++++++++++++++++++++++++++++++++++- src/query_predicates.cc | 42 +++++++++- tests/database_test.cc | 26 ++++-- 4 files changed, 299 insertions(+), 15 deletions(-) diff --git a/include/query_predicates.h b/include/query_predicates.h index e29b275..098731d 100644 --- a/include/query_predicates.h +++ b/include/query_predicates.h @@ -42,6 +42,10 @@ struct REFLECTION_EXPORT SqlValue { bool bool_value; double real_value; std::string text_value; + + /// When true, the value represents SQL NULL (an unset nullable member) and is bound + /// via sqlite3_bind_null; the typed value members above are then meaningless + bool is_null; }; /// The base class of all WHERE predicates used in SQLite queries @@ -87,14 +91,25 @@ class REFLECTION_EXPORT QueryPredicate : public QueryPredicateBase { for (auto i = 0; i < record.member_metadata.size(); ++i) { if (record.member_metadata[i].offset == offset) { member_name_ = record.member_metadata[i].name; - value_ = value_retrieval((void*)&value, record.member_metadata[i].storage_class); + auto value_address = (void*)&value; + if (record.member_metadata[i].nullable) { + // A value predicate on a nullable member compares against the contained + // value; an empty optional cannot be expressed as "= ?" in SQL (a bound + // NULL never matches), so demand IsNull()/IsNotNull() for that instead + value_address = NullableContainedValue(value_address, record.member_metadata[i].storage_class); + if (value_address == nullptr) { + throw std::invalid_argument("Value predicate on nullable member '" + member_name_ + + "' was given an empty optional; use IsNull()/IsNotNull() instead"); + } + } + value_ = value_retrieval(value_address, record.member_metadata[i].storage_class); found = true; break; } } if (!found) { throw std::runtime_error("No registered member of '" + record.name + - "' matches the given pointer-to-member (type id: " + typeid(T).name() + ")"); + "' matches the given pointer-to-member (type id: " + typeid(T).name() + ")"); } } @@ -111,6 +126,14 @@ class REFLECTION_EXPORT QueryPredicate : public QueryPredicateBase { /// 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; +private: + /// For a type-erased pointer to an fcpp::optional_t of the underlying type of the given + /// storage class, returns a pointer to the contained value, or nullptr when the optional + /// is empty. Lets the constructor above unwrap a nullable comparison value once, so the + /// virtual GetSqlValue overloads only ever see the plain underlying types + static void* NullableContainedValue(void* v, SqliteStorageClass storage_class); + +protected: /// The symbol used for the comparison, for example "=" for equality std::string symbol_; @@ -177,6 +200,59 @@ class REFLECTION_EXPORT Like final : public QueryPredicate { SqlValue GetSqlValue(void* v, SqliteStorageClass storage_class) const override; }; +/// A predicate testing a column for the presence or absence of SQL NULL. Unlike the value +/// predicates it binds no value ("= NULL" never matches in SQL; nullness needs the dedicated +/// IS NULL / IS NOT NULL syntax), so it derives from QueryPredicateBase directly +class REFLECTION_EXPORT NullnessPredicate : public QueryPredicateBase { +public: + std::string Evaluate() const override; + std::vector Bindings() const override; + QueryPredicateBase* Clone() const override; + +protected: + template + NullnessPredicate(R T::* fn, const std::string& symbol) : symbol_(symbol) { + auto record = GetRecordFromTypeId(typeid(T).name()); + auto offset = OffsetFromStart(fn); + auto found = false; + for (auto i = 0; i < record.member_metadata.size(); ++i) { + if (record.member_metadata[i].offset == offset) { + member_name_ = record.member_metadata[i].name; + found = true; + break; + } + } + if (!found) { + throw std::runtime_error("No registered member of '" + record.name + + "' matches the given pointer-to-member (type id: " + typeid(T).name() + ")"); + } + } + + NullnessPredicate(const std::string& symbol, const std::string& member_name) + : symbol_(symbol), member_name_(member_name) {} + + /// The nullness test, either "IS NULL" or "IS NOT NULL" + std::string symbol_; + + /// The name of the tested member, as defined in source code + std::string member_name_; +}; + +/// A predicate matching rows whose column holds SQL NULL, i.e. rows whose +/// nullable member was saved in the unset/empty state +class REFLECTION_EXPORT IsNull final : public NullnessPredicate { +public: + template + explicit IsNull(R T::* fn) : NullnessPredicate(fn, "IS NULL") {} +}; + +/// A predicate matching rows whose column holds a present (non-NULL) value +class REFLECTION_EXPORT IsNotNull final : public NullnessPredicate { +public: + template + explicit IsNotNull(R T::* fn) : NullnessPredicate(fn, "IS NOT NULL") {} +}; + /// A wrapper for a comparison predicate, for which the value of the /// struct member is required to be greater than a given control value class REFLECTION_EXPORT GreaterThan final : public QueryPredicate { diff --git a/src/queries.cc b/src/queries.cc index 5148b67..43121a0 100644 --- a/src/queries.cc +++ b/src/queries.cc @@ -42,6 +42,12 @@ std::string Placeholders(size_t count) { } void BindValue(sqlite3_stmt* stmt, int index, const SqlValue& value) { + if (value.is_null) { + // An unset nullable member is stored as SQL NULL, regardless of its storage class + sqlite3_bind_null(stmt, index); + return; + } + switch (value.storage_class) { case SqliteStorageClass::kInt: sqlite3_bind_int64(stmt, index, value.int_value); @@ -69,6 +75,135 @@ void BindValues(sqlite3_stmt* stmt, const std::vector& values) { } } +/// Reads a nullable member (an fcpp::optional_t of the underlying type of the given storage +/// class) into an SqlValue: the contained value when set, or a null SqlValue when empty +SqlValue GetNullableValue(char* member_address, SqliteStorageClass storage_class) { + SqlValue value; + value.storage_class = storage_class; + switch (storage_class) { + case SqliteStorageClass::kInt: { + auto& optional = *reinterpret_cast*>(member_address); + if (optional.has_value()) { + value.int_value = *optional; + } else { + value.is_null = true; + } + break; + } + + case SqliteStorageClass::kBool: { + auto& optional = *reinterpret_cast*>(member_address); + if (optional.has_value()) { + value.bool_value = *optional; + } else { + value.is_null = true; + } + break; + } + + case SqliteStorageClass::kReal: { + auto& optional = *reinterpret_cast*>(member_address); + if (optional.has_value()) { + value.real_value = *optional; + } else { + value.is_null = true; + } + break; + } + + case SqliteStorageClass::kText: { + auto& optional = *reinterpret_cast*>(member_address); + if (optional.has_value()) { + value.text_value = StringUtilities::ToUtf8(*optional); + } else { + value.is_null = true; + } + break; + } + + case SqliteStorageClass::kDateTime: { + auto& optional = *reinterpret_cast*>(member_address); + if (optional.has_value()) { + value.text_value = StringUtilities::ToUtf8((*optional).SystemTime()); + } else { + value.is_null = true; + } + break; + } + + default: + break; + } + return value; +} + +/// Hydrates a nullable member (an fcpp::optional_t of the underlying type of the given storage +/// class) from the current statement row: SQL NULL sets the optional to empty, a present value +/// is assigned as the contained value. An empty TEXT hydrates to a contained empty string - +/// distinct from NULL - while an empty DATETIME string carries no parseable instant and is +/// treated as absent, like NULL. The caller has already excluded BLOB columns. +void HydrateNullableColumn(sqlite3_stmt* stmt, char* member_address, SqliteStorageClass storage_class, int col, + int col_type) { + switch (storage_class) { + case SqliteStorageClass::kInt: { + auto& optional = *reinterpret_cast*>(member_address); + if (col_type == SQLITE_NULL) { + optional = fcpp::optional_t(); + } else { + optional = static_cast(sqlite3_column_int64(stmt, col)); + } + break; + } + + case SqliteStorageClass::kBool: { + auto& optional = *reinterpret_cast*>(member_address); + if (col_type == SQLITE_NULL) { + optional = fcpp::optional_t(); + } else { + optional = sqlite3_column_int64(stmt, col) == 1; + } + break; + } + + case SqliteStorageClass::kReal: { + auto& optional = *reinterpret_cast*>(member_address); + if (col_type == SQLITE_NULL) { + optional = fcpp::optional_t(); + } else { + optional = sqlite3_column_double(stmt, col); + } + break; + } + + case SqliteStorageClass::kText: { + auto& optional = *reinterpret_cast*>(member_address); + if (col_type == SQLITE_NULL) { + optional = fcpp::optional_t(); + } else { + const auto byte_count = sqlite3_column_bytes(stmt, col); + const auto content = reinterpret_cast(sqlite3_column_text(stmt, col)); + optional = byte_count == 0 ? std::wstring() : StringUtilities::FromUtf8(content, byte_count); + } + break; + } + + case SqliteStorageClass::kDateTime: { + auto& optional = *reinterpret_cast*>(member_address); + const auto byte_count = col_type == SQLITE_NULL ? 0 : sqlite3_column_bytes(stmt, col); + if (byte_count == 0) { + optional = fcpp::optional_t(); + } else { + const auto content = reinterpret_cast(sqlite3_column_text(stmt, col)); + optional = TimePoint::FromSystemTime(StringUtilities::FromUtf8(content, byte_count)); + } + break; + } + + default: + break; + } +} + Query::Query(sqlite3* db, const Reflection& record) : db_(db), record_(record) {} std::string Query::JoinedRecordColumnNames() const { @@ -135,6 +270,12 @@ std::vector ExecutionQuery::GetValues(void* p, bool skip_id) const { // The id is always the first member (index 0); skip it when the caller asks for (size_t j = skip_id ? 1 : 0; j < members.size(); j++) { const auto current_storage_class = members[j].storage_class; + + if (members[j].nullable) { + values.emplace_back(GetNullableValue(GetMemberAddress(p, record_, j), current_storage_class)); + continue; + } + SqlValue value; value.storage_class = current_storage_class; @@ -195,8 +336,16 @@ std::string CreateTableQuery::CustomizedColumnName(size_t index) const { name += " " + record_.member_metadata[index].sqlite_column_name; // 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; + // highest id is deleted (an INTEGER PRIMARY KEY is implicitly NOT NULL in SQLite) + if (is_id) { + return name + " PRIMARY KEY AUTOINCREMENT"; + } + + // Non-nullable members get an explicit NOT NULL, so the schema enforces the contract the + // object model assumes; nullable members leave the column physically nullable. Since + // tables are created with CREATE TABLE IF NOT EXISTS, this only affects newly created + // tables - pre-existing database files keep their schema until migrated + return record_.member_metadata[index].nullable ? name : name + " NOT NULL"; } DeleteQuery::DeleteQuery(sqlite3* db, const Reflection& record, const QueryPredicateBase* predicate) @@ -305,6 +454,18 @@ void FetchRecordsQuery::HydrateCurrentRow(void* p, const Reflection& record) con const auto column_count = std::min(static_cast(sqlite3_column_count(stmt_)), record.member_metadata.size()); for (size_t j = 0; j < column_count; j++) { const auto col = static_cast(j); + const int col_type = sqlite3_column_type(stmt_, col); + + if (record.member_metadata[j].nullable) { + // A nullable member carries "absent" as a first-class state: SQL NULL hydrates to + // an empty optional instead of being skipped. BLOB stays skipped for the same + // accessor-safety reason as for non-nullable members below. + if (col_type != SQLITE_BLOB) { + HydrateNullableColumn(stmt_, GetMemberAddress(p, record, j), record.member_metadata[j].storage_class, + col, col_type); + } + continue; + } // The prior text-based path only ever produced a non-empty string for INTEGER, // FLOAT, or TEXT columns; NULL and BLOB both fell through to an empty string and @@ -312,7 +473,6 @@ 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 int col_type = sqlite3_column_type(stmt_, col); if (col_type == SQLITE_NULL || col_type == SQLITE_BLOB) { continue; } diff --git a/src/query_predicates.cc b/src/query_predicates.cc index 3ab46f9..4c59235 100644 --- a/src/query_predicates.cc +++ b/src/query_predicates.cc @@ -47,12 +47,52 @@ 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_); } +void* QueryPredicate::NullableContainedValue(void* v, SqliteStorageClass storage_class) { + switch (storage_class) { + case SqliteStorageClass::kInt: { + auto& optional = *reinterpret_cast*>(v); + return optional.has_value() ? (void*)&*optional : nullptr; + } + case SqliteStorageClass::kBool: { + auto& optional = *reinterpret_cast*>(v); + return optional.has_value() ? (void*)&*optional : nullptr; + } + case SqliteStorageClass::kReal: { + auto& optional = *reinterpret_cast*>(v); + return optional.has_value() ? (void*)&*optional : nullptr; + } + case SqliteStorageClass::kText: { + auto& optional = *reinterpret_cast*>(v); + return optional.has_value() ? (void*)&*optional : nullptr; + } + case SqliteStorageClass::kDateTime: { + auto& optional = *reinterpret_cast*>(v); + return optional.has_value() ? (void*)&*optional : nullptr; + } + default: + throw std::domain_error("Blob cannot be used in a nullable comparison"); + } +} + +std::string NullnessPredicate::Evaluate() const { + return member_name_ + space + symbol_; +} + +std::vector NullnessPredicate::Bindings() const { + return {}; +} + +QueryPredicateBase* NullnessPredicate::Clone() const { + return new NullnessPredicate(symbol_, member_name_); +} + std::string EmptyPredicate::Evaluate() const { return ""; } diff --git a/tests/database_test.cc b/tests/database_test.cc index 29cc875..cd35c26 100644 --- a/tests/database_test.cc +++ b/tests/database_test.cc @@ -753,15 +753,23 @@ TEST_F(DatabaseTest, FetchPreservesHighPrecisionDoubleValues) { TEST_F(DatabaseTest, FetchPreservesNullAndEmptyStringSkipSemantics) { const auto db = Database::Instance(); - // 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 - // a genuine empty string - matching the pre-refactor behavior exactly. Both are only - // observable here for wstring members: std::wstring's default constructor deterministically - // produces an empty string regardless of whether it was assigned, whereas a skipped - // scalar member (e.g. an omitted INTEGER column) keeps whatever indeterminate value - // T's default construction happens to leave it at - both before and after this refactor - - // so that case isn't asserted on here. Resolving the NULL/empty-string conflation itself - // is tracked separately as #20 and is out of scope here. + // Columns omitted from a raw INSERT are stored as SQL NULL. For NON-nullable members, + // direct hydration must skip assignment for a NULL column, and must also skip assignment + // for a TEXT column holding a genuine empty string. Both are only observable here for + // wstring members: std::wstring's default constructor deterministically produces an empty + // string regardless of whether it was assigned, whereas a skipped scalar member (e.g. an + // omitted INTEGER column) keeps whatever indeterminate value T's default construction + // happens to leave it at, so that case isn't asserted on here. Nullable members hydrate + // NULL as an empty optional instead - see nullable_test.cc. + // + // Freshly created tables now carry NOT NULL on non-nullable columns, so a NULL can no + // longer be inserted into them through this library's own schema. Recreate the table the + // way a legacy database file (created before NOT NULL was emitted) or a foreign file + // would look, which is exactly the scenario the skip semantics protect. + db->UnsafeSql("DROP TABLE Company"); + db->UnsafeSql( + "CREATE TABLE Company (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, age INTEGER, " + "address TEXT, salary REAL)"); db->UnsafeSql("INSERT INTO Company (id, name, age, salary) VALUES (1, '', 30, 50000.0)"); db->UnsafeSql("INSERT INTO Company (id, age, address, salary) VALUES (2, 31, 'Nowhere', 60000.0)"); From 2686fb96ec23fa9f04883f85205b0010fb9e961c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 07:14:13 +0000 Subject: [PATCH 4/9] Test nullable fields end to end (#2, #20) NullableRecord mixes a non-nullable member with a nullable member of every storage class. Covered: unset fields round-trip as SQL NULL (with IS NULL matching at the SQL level as raw proof); set fields round-trip equal; NULL is distinguishable from a stored empty string and stored zero after fetch (the #20 core case); non-nullable columns reject NULL while nullable ones accept it (schema NOT NULL, asserted behaviorally); IsNull/IsNotNull select the right rows, also inside And() compounds; value predicates operate on the contained value and fail fast on an empty optional; a record without nullable members round-trips unchanged. Under C++17+ a static_assert pins that nullable fields are literally std::optional. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01NUt3c1wdseRtRSSXfg3MCK --- tests/nullable_record.h | 37 +++++++ tests/nullable_test.cc | 227 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 264 insertions(+) create mode 100644 tests/nullable_record.h create mode 100644 tests/nullable_test.cc diff --git a/tests/nullable_record.h b/tests/nullable_record.h new file mode 100644 index 0000000..2becb69 --- /dev/null +++ b/tests/nullable_record.h @@ -0,0 +1,37 @@ +// 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 + +#include + +// A record mixing one non-nullable member with a nullable member of every storage class, +// used to exercise the nullable-field feature end to end +#define REFLECTABLE NullableRecord +#define FIELDS \ + MEMBER_TEXT(label) \ + MEMBER_INT_NULLABLE(maybe_int) \ + MEMBER_REAL_NULLABLE(maybe_real) \ + MEMBER_TEXT_NULLABLE(maybe_text) \ + MEMBER_BOOL_NULLABLE(maybe_bool) \ + MEMBER_DATETIME_NULLABLE(maybe_date) +#include "reflection.h" diff --git a/tests/nullable_test.cc b/tests/nullable_test.cc new file mode 100644 index 0000000..f9f5370 --- /dev/null +++ b/tests/nullable_test.cc @@ -0,0 +1,227 @@ +// 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. + +#include + +#include +#include +#include + +#include "database.h" +#include "nullable_record.h" +#include "person.h" + +using namespace sqlite_reflection; + +// Under C++17 and later, a nullable field is literally std::optional of the underlying type +// (fcpp::optional_t aliases std::optional there); under C++11 an equivalent fallback is used. +// The tests below only use the API surface common to both (has_value, operator*, assignment). +#if __cplusplus >= 201703L +static_assert(std::is_same>::value, + "a nullable INT field must be std::optional under C++17+"); +static_assert(std::is_same>::value, + "a nullable TEXT field must be std::optional under C++17+"); +#endif + +class NullableTest : public ::testing::Test { + void SetUp() override { + Database::Initialize(""); + } + + void TearDown() override { + Database::Finalize(); + } +}; + +TEST_F(NullableTest, UnsetNullableFieldsRoundTripAsNull) { + const auto db = Database::Instance(); + + NullableRecord unset; + unset.label = L"unset"; + unset.id = 1; + db->Save(unset); + + // Every nullable field hydrates back to the empty/absent state + const auto fetched = db->Fetch(1); + EXPECT_EQ(L"unset", fetched.label); + EXPECT_FALSE(fetched.maybe_int.has_value()); + EXPECT_FALSE(fetched.maybe_real.has_value()); + EXPECT_FALSE(fetched.maybe_text.has_value()); + EXPECT_FALSE(fetched.maybe_bool.has_value()); + EXPECT_FALSE(fetched.maybe_date.has_value()); + + // SQL-level proof that a real NULL was stored: SQLite's own IS NULL matches the row for + // each storage class, which a bound value or empty string never would + EXPECT_EQ(1, db->Fetch(&IsNull(&NullableRecord::maybe_int)).size()); + EXPECT_EQ(1, db->Fetch(&IsNull(&NullableRecord::maybe_real)).size()); + EXPECT_EQ(1, db->Fetch(&IsNull(&NullableRecord::maybe_text)).size()); + EXPECT_EQ(1, db->Fetch(&IsNull(&NullableRecord::maybe_bool)).size()); + EXPECT_EQ(1, db->Fetch(&IsNull(&NullableRecord::maybe_date)).size()); +} + +TEST_F(NullableTest, SetNullableFieldsRoundTripEqual) { + const auto db = Database::Instance(); + + NullableRecord set; + set.label = L"set"; + set.maybe_int = static_cast(42); + set.maybe_real = 2.5; + set.maybe_text = std::wstring(L"hello"); + set.maybe_bool = true; + set.maybe_date = TimePoint(static_cast(1600000000)); + set.id = 1; + db->Save(set); + + const auto fetched = db->Fetch(1); + ASSERT_TRUE(fetched.maybe_int.has_value()); + EXPECT_EQ(42, *fetched.maybe_int); + ASSERT_TRUE(fetched.maybe_real.has_value()); + EXPECT_EQ(2.5, *fetched.maybe_real); + ASSERT_TRUE(fetched.maybe_text.has_value()); + EXPECT_EQ(L"hello", *fetched.maybe_text); + ASSERT_TRUE(fetched.maybe_bool.has_value()); + EXPECT_TRUE(*fetched.maybe_bool); + ASSERT_TRUE(fetched.maybe_date.has_value()); + EXPECT_EQ(TimePoint(static_cast(1600000000)).SystemTime(), (*fetched.maybe_date).SystemTime()); +} + +TEST_F(NullableTest, NullIsDistinguishableFromEmptyStringAndZero) { + const auto db = Database::Instance(); + + // The #20 core case: an absent value, an explicitly stored empty string, and an explicitly + // stored zero must all be distinguishable after a round-trip + NullableRecord absent; + absent.label = L"absent"; + absent.id = 1; + db->Save(absent); + + NullableRecord present; + present.label = L"present"; + present.maybe_text = std::wstring(); + present.maybe_int = static_cast(0); + present.maybe_real = 0.0; + present.id = 2; + db->Save(present); + + const auto fetched_absent = db->Fetch(1); + EXPECT_FALSE(fetched_absent.maybe_text.has_value()); + EXPECT_FALSE(fetched_absent.maybe_int.has_value()); + EXPECT_FALSE(fetched_absent.maybe_real.has_value()); + + const auto fetched_present = db->Fetch(2); + ASSERT_TRUE(fetched_present.maybe_text.has_value()); + EXPECT_EQ(L"", *fetched_present.maybe_text); + ASSERT_TRUE(fetched_present.maybe_int.has_value()); + EXPECT_EQ(0, *fetched_present.maybe_int); + ASSERT_TRUE(fetched_present.maybe_real.has_value()); + EXPECT_EQ(0.0, *fetched_present.maybe_real); +} + +TEST_F(NullableTest, SchemaEmitsNotNullForNonNullableColumnsOnly) { + const auto db = Database::Instance(); + + // Non-nullable columns now carry NOT NULL in the generated schema, so SQLite itself + // rejects a NULL - both in a purely non-nullable record and for the non-nullable member + // of a mixed record + EXPECT_ANY_THROW( + db->UnsafeSql("INSERT INTO Person (first_name, last_name, age, is_vaccinated) VALUES (NULL, 'x', 1, 0)")); + EXPECT_ANY_THROW(db->UnsafeSql("INSERT INTO NullableRecord (label) VALUES (NULL)")); + + // Nullable columns carry no NOT NULL: explicit and implicit (omitted-column) NULLs are + // accepted + db->UnsafeSql("INSERT INTO NullableRecord (label, maybe_int) VALUES ('x', NULL)"); + const auto all = db->FetchAll(); + ASSERT_EQ(1, all.size()); + EXPECT_FALSE(all[0].maybe_int.has_value()); + EXPECT_FALSE(all[0].maybe_text.has_value()); +} + +TEST_F(NullableTest, IsNullAndIsNotNullSelectTheRightRows) { + const auto db = Database::Instance(); + + NullableRecord unset; + unset.label = L"unset"; + unset.id = 1; + db->Save(unset); + + NullableRecord set; + set.label = L"set"; + set.maybe_int = static_cast(7); + set.id = 2; + db->Save(set); + + const auto is_null = IsNull(&NullableRecord::maybe_int); + const auto null_rows = db->Fetch(&is_null); + ASSERT_EQ(1, null_rows.size()); + EXPECT_EQ(1, null_rows[0].id); + + const auto is_not_null = IsNotNull(&NullableRecord::maybe_int); + const auto not_null_rows = db->Fetch(&is_not_null); + ASSERT_EQ(1, not_null_rows.size()); + EXPECT_EQ(2, not_null_rows[0].id); + + // The nullness predicates survive Clone() inside And()/Or() compounds, like every + // other predicate + const auto combined = IsNull(&NullableRecord::maybe_int).And(Equal(&NullableRecord::label, L"unset")); + const auto combined_rows = db->Fetch(&combined); + ASSERT_EQ(1, combined_rows.size()); + EXPECT_EQ(1, combined_rows[0].id); +} + +TEST_F(NullableTest, ValuePredicatesOperateOnTheContainedValue) { + const auto db = Database::Instance(); + + NullableRecord set; + set.label = L"set"; + set.maybe_int = static_cast(7); + set.id = 1; + db->Save(set); + + NullableRecord unset; + unset.label = L"unset"; + unset.id = 2; + db->Save(unset); + + // A value predicate on a nullable member compares against the contained value + const auto equal_seven = Equal(&NullableRecord::maybe_int, fcpp::optional_t(static_cast(7))); + const auto rows = db->Fetch(&equal_seven); + ASSERT_EQ(1, rows.size()); + EXPECT_EQ(1, rows[0].id); + + // Handing a value predicate an empty optional is a misuse ("= NULL" never matches in + // SQL); it fails fast with a pointer to IsNull()/IsNotNull() + EXPECT_THROW(Equal(&NullableRecord::maybe_int, fcpp::optional_t()), std::invalid_argument); +} + +TEST_F(NullableTest, NonNullableRecordsRoundTripUnchanged) { + const auto db = Database::Instance(); + + // Regression: a record without any nullable members behaves exactly as before + const Person p{L"ada", L"lovelace", 36, true, 1}; + db->Save(p); + + const auto fetched = db->Fetch(1); + EXPECT_EQ(p.first_name, fetched.first_name); + EXPECT_EQ(p.last_name, fetched.last_name); + EXPECT_EQ(p.age, fetched.age); + EXPECT_EQ(p.is_vaccinated, fetched.is_vaccinated); +} From 0524f43afaf4c0e7239029238d8fe80ea17d2efe Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 07:14:13 +0000 Subject: [PATCH 5/9] Document nullable fields in the README (#2) Covers the MEMBER_*_NULLABLE macros, the fcpp::optional_t representation (std::optional under C++17+, C++11 fallback otherwise), NULL round-trip semantics, IsNull/IsNotNull and value-predicate behavior, and the NOT NULL migration caveat for pre-existing database files, mirroring the existing AUTOINCREMENT caveat. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01NUt3c1wdseRtRSSXfg3MCK --- README.md | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/README.md b/README.md index c09c8cc..066e9f2 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,55 @@ Supported field macros: | `sqlite_reflection::TimePoint` | `MEMBER_DATETIME(name)` | | member function declaration | `FUNC(signature)` | +### Nullable fields + +Each field macro has a `_NULLABLE` variant (`MEMBER_INT_NULLABLE`, `MEMBER_REAL_NULLABLE`, +`MEMBER_TEXT_NULLABLE`, `MEMBER_BOOL_NULLABLE`, `MEMBER_DATETIME_NULLABLE`) declaring a field +that can represent the absence of a value. The field's type is `fcpp::optional_t` from +[functional_cpp](https://github.com/jkalias/functional_cpp): under C++17 and later that is +literally `std::optional`, so you get the standard type and API; under C++11 an equivalent +fallback with the core API (`has_value()`, `value()`, `operator*`/`->`, assignment from a value) +is used. + +```c++ +#define REFLECTABLE Employee +#define FIELDS \ +MEMBER_TEXT(name) \ +MEMBER_TEXT_NULLABLE(middle_name) \ +MEMBER_REAL_NULLABLE(salary) +#include "reflection.h" + +Employee e; +e.name = L"Ada Lovelace"; // middle_name and salary stay unset +db->Save(e); // unset fields are stored as SQL NULL + +const auto fetched = db->Fetch(e.id); +if (fetched.salary.has_value()) { /* a real value was stored */ } +``` + +Round-trip semantics: saving an unset nullable field binds SQL `NULL` +(`sqlite3_bind_null`), and fetching a `NULL` column hydrates the field back to the empty +state. A stored empty string and a stored zero are distinct from `NULL` and hydrate to a +*present* empty/zero value — absence and emptiness are no longer conflated. + +Two predicates cover nullness tests, since SQL's `= NULL` never matches: + +```c++ +const auto unpaid = db->Fetch(&IsNull(&Employee::salary)); +const auto paid = db->Fetch(&IsNotNull(&Employee::salary)); +``` + +Value predicates (`Equal`, `Like`, ...) on a nullable field compare against the contained +value and take an optional as the comparison argument, e.g. +`Equal(&Employee::salary, fcpp::optional_t(50000.0))`. Passing an *empty* optional to a +value predicate throws `std::invalid_argument` — use `IsNull`/`IsNotNull` for that instead. + +Non-nullable columns are now created with an explicit `NOT NULL` constraint, so the schema +enforces what the object model assumes. Because tables are created with +`CREATE TABLE IF NOT EXISTS`, this only affects newly created tables; pre-existing database +files keep their previous schema until migrated (the library has no migration layer yet) — +the same caveat that applies to `AUTOINCREMENT`. + **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 From 096d18cc1cc6617670a15d09936673273de1a564 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 07:16:20 +0000 Subject: [PATCH 6/9] Bind IsNull predicates to locals before taking their address Apple Clang errors on &IsNull(...) with -Waddress-of-temporary (taking the address of a temporary object), failing both macOS CI jobs; GCC only tolerated it via the project's -fpermissive. Bind each predicate to a named const local first, matching how every other predicate call site in the tests already does it. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01NUt3c1wdseRtRSSXfg3MCK --- tests/nullable_test.cc | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/tests/nullable_test.cc b/tests/nullable_test.cc index f9f5370..57cc9ec 100644 --- a/tests/nullable_test.cc +++ b/tests/nullable_test.cc @@ -71,11 +71,16 @@ TEST_F(NullableTest, UnsetNullableFieldsRoundTripAsNull) { // SQL-level proof that a real NULL was stored: SQLite's own IS NULL matches the row for // each storage class, which a bound value or empty string never would - EXPECT_EQ(1, db->Fetch(&IsNull(&NullableRecord::maybe_int)).size()); - EXPECT_EQ(1, db->Fetch(&IsNull(&NullableRecord::maybe_real)).size()); - EXPECT_EQ(1, db->Fetch(&IsNull(&NullableRecord::maybe_text)).size()); - EXPECT_EQ(1, db->Fetch(&IsNull(&NullableRecord::maybe_bool)).size()); - EXPECT_EQ(1, db->Fetch(&IsNull(&NullableRecord::maybe_date)).size()); + const auto int_is_null = IsNull(&NullableRecord::maybe_int); + EXPECT_EQ(1, db->Fetch(&int_is_null).size()); + const auto real_is_null = IsNull(&NullableRecord::maybe_real); + EXPECT_EQ(1, db->Fetch(&real_is_null).size()); + const auto text_is_null = IsNull(&NullableRecord::maybe_text); + EXPECT_EQ(1, db->Fetch(&text_is_null).size()); + const auto bool_is_null = IsNull(&NullableRecord::maybe_bool); + EXPECT_EQ(1, db->Fetch(&bool_is_null).size()); + const auto date_is_null = IsNull(&NullableRecord::maybe_date); + EXPECT_EQ(1, db->Fetch(&date_is_null).size()); } TEST_F(NullableTest, SetNullableFieldsRoundTripEqual) { From 3ee9551751a04e478ac16fb14fc73ae2c3f23b6d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 07:21:29 +0000 Subject: [PATCH 7/9] Guard functional_cpp population for CMake older than 3.18 FetchContent's SOURCE_SUBDIR handling was added in CMake 3.18, but the project's minimum is 3.14; on 3.14-3.17, MakeAvailable would have add_subdirectory'd functional_cpp's top-level CMakeLists.txt, pulling its own src/tests into this build. Branch on the CMake version: 3.18+ keeps the SOURCE_SUBDIR MakeAvailable path, older versions populate the sources via FetchContent_Populate, which never calls add_subdirectory at all. The deprecated-in-3.30 Populate call sits behind the VERSION_LESS 3.18 check, so modern CMake never executes it. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01NUt3c1wdseRtRSSXfg3MCK --- CMakeLists.txt | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 1143abb..3790c1b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -26,16 +26,27 @@ FetchContent_MakeAvailable(googletest) # functional_cpp provides fcpp::optional_t, the nullable-field representation # (std::optional under C++17 and later, an equivalent C++11 fallback otherwise). -# It is consumed header-only: SOURCE_SUBDIR points at the header directory, which -# contains no CMakeLists.txt, so FetchContent only downloads the sources without -# building functional_cpp's own library and test targets. +# It is consumed header-only: only its sources are downloaded, and functional_cpp's +# own library and test targets are never added to this build. FetchContent_Declare( functional_cpp GIT_REPOSITORY https://github.com/jkalias/functional_cpp.git GIT_TAG 1.1.1 SOURCE_SUBDIR include ) -FetchContent_MakeAvailable(functional_cpp) +if(CMAKE_VERSION VERSION_LESS 3.18) + # FetchContent's SOURCE_SUBDIR support was added in CMake 3.18; on older versions + # MakeAvailable would add_subdirectory functional_cpp's top-level CMakeLists.txt, + # pulling its src/tests into this build. Populate without adding any subdirectory. + FetchContent_GetProperties(functional_cpp) + if(NOT functional_cpp_POPULATED) + FetchContent_Populate(functional_cpp) + endif() +else() + # SOURCE_SUBDIR points at the header directory, which contains no CMakeLists.txt, + # so MakeAvailable populates the sources without calling add_subdirectory + FetchContent_MakeAvailable(functional_cpp) +endif() include_directories(${functional_cpp_SOURCE_DIR}/include) # Set compiler settings based on the current platform From c162223928e11779e99ccc4bd98a733cca339591 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 07:31:41 +0000 Subject: [PATCH 8/9] Support nullable members in range predicates GreaterThan, GreaterThanOrEqual, SmallerThan and SmallerThanOrEqual only exposed int64_t and double member-pointer overloads, so they could not be used with nullable numeric members at all, even though value predicates on nullable members are documented to compare against the contained value. Add fcpp::optional_t and fcpp::optional_t overloads that route through the existing QueryPredicate constructor, which already unwraps the optional and fails fast on an empty one. Also fix the README nullness-predicate example, which took the address of a temporary (the pattern Clang and MSVC reject), and mention the range predicates in the nullable value-predicate documentation. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01NUt3c1wdseRtRSSXfg3MCK --- README.md | 16 +++++++++----- include/query_predicates.h | 32 +++++++++++++++++++++++++++ tests/nullable_test.cc | 45 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 066e9f2..20cdc0c 100644 --- a/README.md +++ b/README.md @@ -169,14 +169,18 @@ state. A stored empty string and a stored zero are distinct from `NULL` and hydr Two predicates cover nullness tests, since SQL's `= NULL` never matches: ```c++ -const auto unpaid = db->Fetch(&IsNull(&Employee::salary)); -const auto paid = db->Fetch(&IsNotNull(&Employee::salary)); +const auto is_unpaid = IsNull(&Employee::salary); +const auto unpaid = db->Fetch(&is_unpaid); +const auto is_paid = IsNotNull(&Employee::salary); +const auto paid = db->Fetch(&is_paid); ``` -Value predicates (`Equal`, `Like`, ...) on a nullable field compare against the contained -value and take an optional as the comparison argument, e.g. -`Equal(&Employee::salary, fcpp::optional_t(50000.0))`. Passing an *empty* optional to a -value predicate throws `std::invalid_argument` — use `IsNull`/`IsNotNull` for that instead. +Value predicates (`Equal`, `Like`, `GreaterThan`, `SmallerThanOrEqual`, ...) on a nullable +field compare against the contained value and take an optional as the comparison argument, +e.g. `Equal(&Employee::salary, fcpp::optional_t(50000.0))` or +`GreaterThan(&Employee::salary, fcpp::optional_t(40000.0))`. Passing an *empty* +optional to a value predicate throws `std::invalid_argument` — use `IsNull`/`IsNotNull` for +that instead. Non-nullable columns are now created with an explicit `NOT NULL` constraint, so the schema enforces what the object model assumes. Because tables are created with diff --git a/include/query_predicates.h b/include/query_predicates.h index 098731d..673ecb6 100644 --- a/include/query_predicates.h +++ b/include/query_predicates.h @@ -265,6 +265,14 @@ 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, fcpp::optional_t value) + : QueryPredicate(fn, value, ">") {} + + template + explicit GreaterThan(fcpp::optional_t T::* fn, fcpp::optional_t value) + : QueryPredicate(fn, value, ">") {} }; /// A wrapper for a comparison predicate, for which the value of the @@ -279,6 +287,14 @@ 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, fcpp::optional_t value) + : QueryPredicate(fn, value, ">=") {} + + template + explicit GreaterThanOrEqual(fcpp::optional_t T::* fn, fcpp::optional_t value) + : QueryPredicate(fn, value, ">=") {} }; /// A wrapper for a comparison predicate, for which the value of the @@ -293,6 +309,14 @@ 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, fcpp::optional_t value) + : QueryPredicate(fn, value, "<") {} + + template + explicit SmallerThan(fcpp::optional_t T::* fn, fcpp::optional_t value) + : QueryPredicate(fn, value, "<") {} }; /// A wrapper for a comparison predicate, for which the value of the @@ -307,6 +331,14 @@ 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, fcpp::optional_t value) + : QueryPredicate(fn, value, "<=") {} + + template + explicit SmallerThanOrEqual(fcpp::optional_t T::* fn, fcpp::optional_t value) + : QueryPredicate(fn, value, "<=") {} }; /// A wrapper of a compound predicate, which combines two other predicates, diff --git a/tests/nullable_test.cc b/tests/nullable_test.cc index 57cc9ec..d41dd37 100644 --- a/tests/nullable_test.cc +++ b/tests/nullable_test.cc @@ -217,6 +217,51 @@ TEST_F(NullableTest, ValuePredicatesOperateOnTheContainedValue) { EXPECT_THROW(Equal(&NullableRecord::maybe_int, fcpp::optional_t()), std::invalid_argument); } +TEST_F(NullableTest, RangePredicatesOperateOnTheContainedValue) { + const auto db = Database::Instance(); + + NullableRecord low; + low.label = L"low"; + low.maybe_int = static_cast(3); + low.maybe_real = 1.5; + low.id = 1; + db->Save(low); + + NullableRecord high; + high.label = L"high"; + high.maybe_int = static_cast(9); + high.maybe_real = 9.5; + high.id = 2; + db->Save(high); + + // A row with NULL columns never matches a range comparison in SQL + NullableRecord unset; + unset.label = L"unset"; + unset.id = 3; + db->Save(unset); + + const auto greater = GreaterThan(&NullableRecord::maybe_int, fcpp::optional_t(static_cast(5))); + const auto greater_rows = db->Fetch(&greater); + ASSERT_EQ(1, greater_rows.size()); + EXPECT_EQ(2, greater_rows[0].id); + + const auto greater_equal = + GreaterThanOrEqual(&NullableRecord::maybe_int, fcpp::optional_t(static_cast(3))); + EXPECT_EQ(2, db->Fetch(&greater_equal).size()); + + const auto smaller = SmallerThan(&NullableRecord::maybe_real, fcpp::optional_t(9.5)); + const auto smaller_rows = db->Fetch(&smaller); + ASSERT_EQ(1, smaller_rows.size()); + EXPECT_EQ(1, smaller_rows[0].id); + + const auto smaller_equal = SmallerThanOrEqual(&NullableRecord::maybe_real, fcpp::optional_t(9.5)); + EXPECT_EQ(2, db->Fetch(&smaller_equal).size()); + + // Like every value predicate, an empty optional is a misuse and fails fast + EXPECT_THROW(GreaterThan(&NullableRecord::maybe_int, fcpp::optional_t()), std::invalid_argument); + EXPECT_THROW(SmallerThan(&NullableRecord::maybe_real, fcpp::optional_t()), std::invalid_argument); +} + TEST_F(NullableTest, NonNullableRecordsRoundTripUnchanged) { const auto db = Database::Instance(); From 2711eb3d3280201aeeb0a249569537959d7b7959 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 07:40:16 +0000 Subject: [PATCH 9/9] Use SaveAutoIncrement in the README nullable example The example default-constructed an Employee and saved it via Save, which binds the appended int64_t id without ever writing one back - so the id stayed indeterminate and the subsequent fetch by e.id read an uninitialized value. SaveAutoIncrement assigns the id and writes it back into the object, making the example correct as written. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01NUt3c1wdseRtRSSXfg3MCK --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 20cdc0c..85150bc 100644 --- a/README.md +++ b/README.md @@ -155,7 +155,7 @@ MEMBER_REAL_NULLABLE(salary) Employee e; e.name = L"Ada Lovelace"; // middle_name and salary stay unset -db->Save(e); // unset fields are stored as SQL NULL +db->SaveAutoIncrement(e); // unset fields are stored as SQL NULL; e.id gets assigned const auto fetched = db->Fetch(e.id); if (fetched.salary.has_value()) { /* a real value was stored */ }