Skip to content

Implement nullable reflected fields#38

Merged
jkalias merged 9 commits into
mainfrom
claude/nullable-fields
Jul 19, 2026
Merged

Implement nullable reflected fields#38
jkalias merged 9 commits into
mainfrom
claude/nullable-fields

Conversation

@jkalias

@jkalias jkalias commented Jul 19, 2026

Copy link
Copy Markdown
Owner

Summary

Closes #2. Closes #20.

These two issues share one root cause: a reflected field had no way to represent the absence of a value. Every field macro expanded to an always-present C++ type, so the read path could only skip a SQL NULL (leaving the member at its default — #20's NULL/empty/zero conflation), and the model couldn't declare, write, or query nullable columns at all (#2). This PR adds the missing representation and delivers the whole nullable-field feature; the conflation disappears as a consequence.

The representation — fcpp::optional_t from functional_cpp

Nullable fields are declared via new MEMBER_*_NULLABLE macros (all five storage classes) and typed as fcpp::optional_t<T>, pulled in via FetchContent pinned to tag 1.1.1, consumed header-only (its SOURCE_SUBDIR has no CMakeLists, so none of its own library/test targets build). Verified in its source: under C++17+ optional_t<T> is literally using optional_t = std::optional<T> — a static_assert in the tests pins this — with an equivalent C++11 fallback otherwise (has_value(), value(), operator*/->, assignment). The C++11 minimum is unchanged. Layout-wise an optional member fits the same pattern as the existing non-standard-layout wstring/TimePoint members: non-polymorphic value type, default-constructs to empty, works under offsetof.

What changed

  • Macros/metadata: MEMBER_INT/REAL/TEXT/BOOL/DATETIME_NULLABLE; MemberMetadata gains an orthogonal nullable flag (storage_class keeps describing the underlying type).
  • Write side: SqlValue gains an is_null state bound via sqlite3_bind_null; GetValues reads nullable members through their optional (empty → NULL).
  • Read side (the NULL and empty string are indistinguishable, and empty values are skipped on hydrate #20 fix): HydrateCurrentRow hydrates SQL NULL in a nullable member to an empty optional instead of skipping; a stored empty TEXT hydrates to a contained empty string — NULL, empty, and zero are now all distinguishable. Non-nullable members keep today's exact skip semantics, including the NULL/BLOB accessor-safety skip.
  • Schema: non-nullable columns now emit NOT NULL; nullable ones don't (id keeps PRIMARY KEY AUTOINCREMENT, implicitly NOT NULL). Because tables use CREATE TABLE IF NOT EXISTS, this affects newly created tables only; pre-existing files keep their schema until migrated — documented in the README exactly like the AUTOINCREMENT caveat.
  • Predicates: IsNull(&T::field) / IsNotNull(&T::field) emit IS NULL/IS NOT NULL with no bound value and survive Clone() inside And()/Or(). Value predicates on a nullable member compare against the contained value (pass an optional, e.g. Equal(&T::field, fcpp::optional_t<int64_t>(7))); handing them an empty optional throws std::invalid_argument with a pointer to IsNull/IsNotNull, since = NULL never matches in SQL.

Backward compatibility

Additive: records without nullable macros round-trip identically, byte for byte — the only observable difference is NOT NULL on freshly created tables. One existing test (FetchPreservesNullAndEmptyStringSkipSemantics) inserted NULLs through omitted columns, which the new NOT NULL schema now correctly rejects; its setup now recreates the table the way a legacy/foreign database file would look (the exact scenario its skip-semantics assertions protect), with all assertions unchanged.

Tests

New NullableRecord (one non-nullable member + a nullable member of every storage class), covering:

  • unset fields round-trip as SQL NULL, with IS NULL matching at the SQL level as raw proof of storage
  • set fields round-trip equal (every storage class)
  • NULL and empty string are indistinguishable, and empty values are skipped on hydrate #20 core: NULL vs stored empty string vs stored zero all distinguishable after fetch
  • schema: non-nullable columns reject NULL (both a pure non-nullable record and the mixed record's non-nullable member), nullable columns accept explicit and implicit NULL
  • IsNull/IsNotNull row selection, including inside And() compounds (Clone survival)
  • value-predicate contained-value comparison + fail-fast on empty optional
  • static_assert that nullable fields are std::optional under C++17+
  • regression: a record without nullable members round-trips unchanged

Full suite: 83/83 pass in both C++11 (fallback optional) and C++20 (std::optional) locally on Linux/GCC, plus shuffled-order runs for isolation. macOS/Clang and Windows/MSVC verification via CI (note: MSVC's /Zc:__cplusplus, already set in this project, is what makes functional_cpp's C++17 detection work there).


Generated by Claude Code

claude added 6 commits July 19, 2026 07:14
fcpp::optional_t<T> is the nullable-field representation: it aliases
std::optional<T> 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUt3c1wdseRtRSSXfg3MCK
#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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUt3c1wdseRtRSSXfg3MCK
…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 "<column> 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUt3c1wdseRtRSSXfg3MCK
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUt3c1wdseRtRSSXfg3MCK
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUt3c1wdseRtRSSXfg3MCK
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUt3c1wdseRtRSSXfg3MCK

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0524f43afa

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread CMakeLists.txt
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

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUt3c1wdseRtRSSXfg3MCK

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3ee9551751

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +95 to +99
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);

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

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<int64_t> and fcpp::optional_t<double> 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUt3c1wdseRtRSSXfg3MCK

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c162223928

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread README.md
Comment on lines +156 to +160
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<Employee>(e.id);

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

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUt3c1wdseRtRSSXfg3MCK
@jkalias

jkalias commented Jul 19, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Chef's kiss.

Reviewed commit: 2711eb3d32

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@jkalias
jkalias merged commit cea5f9f into main Jul 19, 2026
15 checks passed
@jkalias
jkalias deleted the claude/nullable-fields branch July 19, 2026 08:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

NULL and empty string are indistinguishable, and empty values are skipped on hydrate Support nullable fields in reflected records

2 participants