diff --git a/CMakeLists.txt b/CMakeLists.txt index c480b8b..3790c1b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -24,6 +24,31 @@ 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: 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 +) +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 if(CMAKE_HOST_UNIX) add_definitions(-w -fpermissive -Wshadow -Wunused -Woverloaded-virtual -Wnon-virtual-dtor -Werror) diff --git a/README.md b/README.md index c09c8cc..85150bc 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,59 @@ 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->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 */ } +``` + +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 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`, `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 +`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 diff --git a/include/query_predicates.h b/include/query_predicates.h index e29b275..673ecb6 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 { @@ -189,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 @@ -203,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 @@ -217,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 @@ -231,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/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; 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)"); 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..d41dd37 --- /dev/null +++ b/tests/nullable_test.cc @@ -0,0 +1,277 @@ +// 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 + 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) { + 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, 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(); + + // 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); +}