Skip to content

Support nullable fields in reflected records #2

@jkalias

Description

@jkalias

Background

Every field of a reflected record is declared through one of the typed macros in include/reflection.hMEMBER_INT, MEMBER_REAL, MEMBER_TEXT, MEMBER_DATETIME, MEMBER_BOOL — and each expands to a concrete, always-present C++ type (int64_t, double, std::wstring, TimePoint, bool). There is no way to express that a field may have no value.

This is a real limitation: nullable columns are a normal part of relational schema design (an optional middle name, an as-yet-unset completion date, a price that is "unknown" rather than 0). Today every field is forced to carry a default-constructed value, so the model cannot distinguish "explicitly empty/zero" from "absent".

Current behavior

  • On the C++ side, a field always holds a value of its underlying type. There is no representation for SQL NULL, so a record round-tripped through the database can never report a missing field.
  • On the SQL side, column definitions are generated in CreateTableQuery::CustomizedColumnName (src/queries.cc) as simply <name> <TYPE> (plus PRIMARY KEY AUTOINCREMENT for id). No NOT NULL constraint is ever emitted, so the columns are physically nullable, but the library has no typed way to read or write a NULL into them. The two layers are out of sync: the schema permits NULL, the object model does not.

Goal

Allow a reflected field to be declared as nullable, so that:

  1. A nullable field can represent the absence of a value in C++ (the natural fit is std::optional<T>, which requires raising the project's minimum standard to C++17 — see note below — or otherwise a library-provided nullable wrapper for C++11).
  2. Saving a record with an unset nullable field writes SQL NULL (bound via sqlite3_bind_null), and fetching a row with a NULL column hydrates the field back to the empty/unset state.
  3. Non-nullable fields gain an explicit NOT NULL constraint in the generated CREATE TABLE, so the schema enforces the contract the object model already assumes.

Open questions / design notes

  • API surface. Options include new macros (e.g. MEMBER_TEXT_NULLABLE(name)) or a wrapping macro applied to existing declarations. The choice should keep the X-macro expansion in reflection.h readable and keep MemberMetadata aware of nullability so query generation and binding can branch on it.
  • C++ standard. std::optional is the obvious representation but is C++17. The README currently advertises a C++11 minimum, so this needs an explicit decision: raise the minimum, or ship a small Nullable<T> type for the C++11 build.
  • Predicates. Nullable fields imply IS NULL / IS NOT NULL tests in query_predicates.h; these should be considered as part of, or as a fast follow to, this work since Equal(&T::field, value) cannot express a NULL comparison in SQL.
  • Migration. Because tables are created with CREATE TABLE IF NOT EXISTS, adding NOT NULL to existing non-nullable columns only affects newly created tables; pre-existing database files keep their current schema until migrated. This caveat mirrors the one already documented for AUTOINCREMENT in the README.

Acceptance criteria

  • A field can be declared nullable and round-trips an unset value as SQL NULL through save/fetch.
  • Non-nullable fields emit NOT NULL in CREATE TABLE.
  • IS NULL / IS NOT NULL predicate support is available (or tracked in a linked follow-up).
  • Tests cover save/fetch of both set and unset nullable fields of each storage class, and the README "Defining records" section documents the nullable macro(s) and the C++-standard requirement.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions