Skip to content
25 changes: 25 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Raise the CMake minimum for SOURCE_SUBDIR

This line relies on FetchContent's SOURCE_SUBDIR handling to avoid adding functional_cpp's own CMake project, but I checked cmake --help-module FetchContent and that support is marked versionadded 3.18 while this project still advertises cmake_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-level CMakeLists.txt, pulling its src/tests into this build rather than just the headers; either raise the minimum to 3.18 or fetch the headers without SOURCE_SUBDIR.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — SOURCE_SUBDIR in FetchContent_Declare is 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 use FetchContent_GetProperties + FetchContent_Populate, which downloads the sources without ever calling add_subdirectory, so functional_cpp's own src/tests targets stay out of the build; on 3.18+ the original SOURCE_SUBDIR include + FetchContent_MakeAvailable path is kept. (FetchContent_Populate is deprecated in CMake 3.30, but that branch is unreachable there since it's behind the VERSION_LESS 3.18 check.) Verified with a fresh configure + full build + 83/83 tests passing.


Generated by Claude Code

)
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)
Expand Down
53 changes: 53 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Initialize the id in the nullable example

When users copy this new example as written, Employee e; leaves the appended int64_t id indeterminate, and Database::Save (unlike SaveAutoIncrement) reads and binds that id without writing one back. The subsequent fetch by e.id therefore uses an uninitialized value; set e.id before Save or use SaveAutoIncrement(e) before fetching.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right — Save binds the id as given and never writes one back, so the example as written saved an indeterminate id and then fetched by it. Fixed in 2711eb3: the example now uses db->SaveAutoIncrement(e), which lets the database assign the id and writes it back into e, making the follow-up Fetch<Employee>(e.id) correct as copied. This matches the README's own guidance further down ("Use SaveAutoIncrement when you want the library to assign the next available id").


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
Expand Down
112 changes: 110 additions & 2 deletions include/query_predicates.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Support nullable inputs in range predicates

When this nullable-member branch is added, it only helps predicate classes whose constructors accept the member's actual fcpp::optional_t<T> type. The range predicates below still expose only int64_t T::* and double T::* overloads, so a natural nullable numeric query such as GreaterThan(&Record::maybe_int, fcpp::optional_t<int64_t>(5)) does not compile even though nullable value predicates are documented as comparing against the contained value. Add optional/generic overloads for the range predicates as well.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — Equal/Unequal/Like have a generic R T::* constructor that deduces nullable member types, but the four range predicates only exposed int64_t T::* and double T::* overloads, so a nullable numeric member couldn't be used with them at all. Fixed in c162223: GreaterThan, GreaterThanOrEqual, SmallerThan and SmallerThanOrEqual now each have fcpp::optional_t<int64_t> and fcpp::optional_t<double> overloads that route through the existing QueryPredicate constructor, so they get the same contained-value comparison and the same fail-fast std::invalid_argument on an empty optional. Covered by a new test (RangePredicatesOperateOnTheContainedValue) exercising all four predicates on nullable INT and REAL members, including NULL rows never matching and the empty-optional throw; 84/84 tests pass under both C++11 and C++20. The README nullable-predicate section now mentions the range predicates too.


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() + ")");
}
}

Expand All @@ -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_;

Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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,
Expand Down
47 changes: 45 additions & 2 deletions include/reflection.h
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
#include <typeinfo>
#include <vector>

#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
Expand All @@ -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;
Expand All @@ -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) {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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<int64_t>, R)
#define MEMBER_REAL_NULLABLE(R) MEMBER_DECLARE(fcpp::optional_t<double>, R)
#define MEMBER_TEXT_NULLABLE(R) MEMBER_DECLARE(fcpp::optional_t<std::wstring>, R)
#define MEMBER_DATETIME_NULLABLE(R) MEMBER_DECLARE(fcpp::optional_t<sqlite_reflection::TimePoint>, R)
#define MEMBER_BOOL_NULLABLE(R) MEMBER_DECLARE(fcpp::optional_t<bool>, R)
#define FUNC(SIGNATURE)
FIELDS
#undef MEMBER_DECLARE
Expand All @@ -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;

Expand All @@ -184,13 +207,23 @@ 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
#undef MEMBER_REAL
#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
};

Expand Down Expand Up @@ -232,13 +265,23 @@ 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
#undef MEMBER_REAL
#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;
Expand Down
Loading
Loading