-
Notifications
You must be signed in to change notification settings - Fork 0
Implement nullable reflected fields #38
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
e2e1773
3371a21
b0e8e3a
2686fb9
0524f43
096d18c
3ee9551
c162223
2711eb3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<T>` from | ||
| [functional_cpp](https://github.com/jkalias/functional_cpp): under C++17 and later that is | ||
| literally `std::optional<T>`, 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<Employee>(e.id); | ||
|
Comment on lines
+156
to
+160
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When users copy this new example as written, Useful? React with 👍 / 👎.
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Right — Generated by Claude Code |
||
| 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<Employee>(&is_unpaid); | ||
| const auto is_paid = IsNotNull(&Employee::salary); | ||
| const auto paid = db->Fetch<Employee>(&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<double>(50000.0))` or | ||
| `GreaterThan(&Employee::salary, fcpp::optional_t<double>(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 | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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); | ||
|
Comment on lines
+95
to
+99
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When this nullable-member branch is added, it only helps predicate classes whose constructors accept the member's actual Useful? React with 👍 / 👎.
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agreed — Generated by Claude Code |
||
| 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<SqlValue> Bindings() const override; | ||
| QueryPredicateBase* Clone() const override; | ||
|
|
||
| protected: | ||
| template <typename T, typename R> | ||
| 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 <typename T, typename R> | ||
| 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 <typename T, typename R> | ||
| 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 <typename T> | ||
| explicit GreaterThan(double T::* fn, double value) : QueryPredicate(fn, value, ">") {} | ||
|
|
||
| template <typename T> | ||
| explicit GreaterThan(fcpp::optional_t<int64_t> T::* fn, fcpp::optional_t<int64_t> value) | ||
| : QueryPredicate(fn, value, ">") {} | ||
|
|
||
| template <typename T> | ||
| explicit GreaterThan(fcpp::optional_t<double> T::* fn, fcpp::optional_t<double> 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 <typename T> | ||
| explicit GreaterThanOrEqual(double T::* fn, double value) : QueryPredicate(fn, value, ">=") {} | ||
|
|
||
| template <typename T> | ||
| explicit GreaterThanOrEqual(fcpp::optional_t<int64_t> T::* fn, fcpp::optional_t<int64_t> value) | ||
| : QueryPredicate(fn, value, ">=") {} | ||
|
|
||
| template <typename T> | ||
| explicit GreaterThanOrEqual(fcpp::optional_t<double> T::* fn, fcpp::optional_t<double> 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 <typename T> | ||
| explicit SmallerThan(double T::* fn, double value) : QueryPredicate(fn, value, "<") {} | ||
|
|
||
| template <typename T> | ||
| explicit SmallerThan(fcpp::optional_t<int64_t> T::* fn, fcpp::optional_t<int64_t> value) | ||
| : QueryPredicate(fn, value, "<") {} | ||
|
|
||
| template <typename T> | ||
| explicit SmallerThan(fcpp::optional_t<double> T::* fn, fcpp::optional_t<double> 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 <typename T> | ||
| explicit SmallerThanOrEqual(double T::* fn, double value) : QueryPredicate(fn, value, "<=") {} | ||
|
|
||
| template <typename T> | ||
| explicit SmallerThanOrEqual(fcpp::optional_t<int64_t> T::* fn, fcpp::optional_t<int64_t> value) | ||
| : QueryPredicate(fn, value, "<=") {} | ||
|
|
||
| template <typename T> | ||
| explicit SmallerThanOrEqual(fcpp::optional_t<double> T::* fn, fcpp::optional_t<double> value) | ||
| : QueryPredicate(fn, value, "<=") {} | ||
| }; | ||
|
|
||
| /// A wrapper of a compound predicate, which combines two other predicates, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This line relies on FetchContent's
SOURCE_SUBDIRhandling to avoid adding functional_cpp's own CMake project, but I checkedcmake --help-module FetchContentand that support is markedversionadded 3.18while this project still advertisescmake_minimum_required(VERSION 3.14). On CMake 3.14-3.17, configuring this commit won't honor the subdir avoidance and can add functional_cpp's top-levelCMakeLists.txt, pulling its src/tests into this build rather than just the headers; either raise the minimum to 3.18 or fetch the headers withoutSOURCE_SUBDIR.Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good catch —
SOURCE_SUBDIRinFetchContent_Declareis indeed 3.18+, while this project declares a 3.14 minimum. Fixed in 3ee9551 without raising the minimum: the population is now version-guarded. On CMake < 3.18 we useFetchContent_GetProperties+FetchContent_Populate, which downloads the sources without ever callingadd_subdirectory, so functional_cpp's own src/tests targets stay out of the build; on 3.18+ the originalSOURCE_SUBDIR include+FetchContent_MakeAvailablepath is kept. (FetchContent_Populateis deprecated in CMake 3.30, but that branch is unreachable there since it's behind theVERSION_LESS 3.18check.) Verified with a fresh configure + full build + 83/83 tests passing.Generated by Claude Code