Skip to content

feat(macros): nullable column attribute for non-Option<T> Rust types#138

Merged
vindard merged 1 commit into
mainfrom
feat/nullable-column-attribute
May 26, 2026
Merged

feat(macros): nullable column attribute for non-Option<T> Rust types#138
vindard merged 1 commit into
mainfrom
feat/nullable-column-attribute

Conversation

@vindard
Copy link
Copy Markdown
Contributor

@vindard vindard commented May 19, 2026

Summary

Column::is_optional() checks the Rust type syntactically — true only when the type literally begins with Option<. That misses a real shape: a domain type whose custom sqlx::Encode impl maps one variant to SQL NULL (e.g. an enum Finite(Decimal) | Infinite that encodes Infinite as IsNull::Yes). Such columns are nullable in SQL but look non-optional to the macro, so they receive the non-nullable cursor SQL form which silently drops NULL rows from page 2+.

The downstream workaround (see GaloyMoney/lana-bank#5898) is to declare ty = "Option<Decimal>" at the storage edge and convert to/from the domain type via a boundary accessor. That works but leaks storage shape into the cursor and the repo accessor, and introduces a separate type at every call site.

Stacks on #137 (direction-aware NULL fallback in cursor condition).

Fix

Add an opt-in nullable attribute on the column declaration:

columns(
    loan_to_collateral_ratio(
        ty = "LoanToCollateralRatio",
        nullable,
        list_by,
        // ...
    ),
)

Now the macro detects nullability from either signal: the syntactic Option<T> shape OR the explicit annotation. Two methods carry the two semantics:

  • Column::is_optional() — Rust type is Option<T>. Drives query parameter casting (avoid double-wrapping in Option) and cursor destructuring (the field is already optional).
  • Column::is_nullable_column() — SQL column can hold NULL. Returns true if either is_optional() or nullable. Drives ORDER BY ... NULLS FIRST/LAST emission and the nullable-aware cursor WHERE form (see fix(macros): direction-aware NULL fallback in cursor condition for nullable sort columns #137).

For a column declared ty = "DomainEnum", nullable:

  • SQL shape: nullable-aware (NULLS FIRST/LAST, IS NOT DISTINCT FROM, direction-aware NULL fallback from fix(macros): direction-aware NULL fallback in cursor condition for nullable sort columns #137 — same as Option<T>).
  • Query parameter binding: value as Option<DomainEnum> (else branch of query_arg_tokens). sqlx encodes the variant that maps to IsNull::Yes as NULL, the rest as their normal payload.
  • Cursor field type: the raw DomainEnum (no Option wrap in the cursor struct — keeps the domain type at the read boundary).

Downstream impact

Once both #137 and this PR land + a release ships, GaloyMoney/lana-bank#5898 can revert its ty = "Option<rust_decimal::Decimal>" workaround back to the cleaner ty = "LoanToCollateralRatio", nullable and restore the custom sqlx::Encode impls on the domain type.

Test plan

  • New unit test list_by_fn_nullable_attribute_emits_nullable_aware_sql_for_non_option_type verifies the emitted SQL matches Option<T>'s (NULLS FIRST/LAST, IS NOT DISTINCT FROM, direction-aware fallback) while the parameter binding stays as Option<DomainEnum>.
  • All 83 macro tests pass (82 existing + 1 new).

🤖 Generated with Claude Code

`Column::is_optional()` checks the Rust type syntactically — true only
when the type literally begins with `Option<`. That misses a real
shape: a domain type whose custom `sqlx::Encode` impl maps one variant
to SQL NULL (e.g. an enum `Finite(Decimal) | Infinite` that encodes
`Infinite` as `IsNull::Yes`). Such columns are nullable in SQL but
look non-optional to the macro, so they receive the non-nullable
cursor SQL form which silently drops NULL rows from page 2+.

The downstream workaround is to declare `ty = "Option<Decimal>"` at
the storage edge and convert to/from the domain type via a boundary
accessor. That works but leaks storage shape into the cursor and the
repo accessor, and introduces a separate type at every call site.

## Fix

Add an opt-in `nullable` attribute on the column declaration:

    columns(
        loan_to_collateral_ratio(
            ty = "LoanToCollateralRatio",
            nullable,
            list_by,
            ...
        ),
    )

Now the macro detects nullability from either signal: the syntactic
`Option<T>` shape OR the explicit annotation. Two methods carry the
two semantics:

  - `Column::is_optional()` — Rust type is `Option<T>`. Drives query
    parameter casting (avoid double-wrapping in Option) and cursor
    destructuring (the field is already optional).
  - `Column::is_nullable_column()` — SQL column can hold NULL. Returns
    true if either `is_optional()` or `nullable`. Drives `ORDER BY
    ... NULLS FIRST/LAST` emission and the nullable-aware cursor
    `WHERE` form.

For a column declared `ty = "DomainEnum", nullable`:

  - SQL shape: nullable-aware (NULLS FIRST/LAST, IS NOT DISTINCT FROM,
    direction-aware NULL fallback — same as Option<T>).
  - Query parameter binding: `value as Option<DomainEnum>` (else
    branch of `query_arg_tokens`). sqlx encodes the variant that maps
    to `IsNull::Yes` as NULL, the rest as their normal payload.
  - Cursor field: the raw `DomainEnum` (no Option wrap in the cursor
    struct — keeps the domain type at the read boundary).

## Tests

  - New unit test `list_by_fn_nullable_attribute_emits_nullable_aware_sql_for_non_option_type`
    verifies the emitted SQL matches the Option<T> shape's
    (`NULLS FIRST/LAST`, `IS NOT DISTINCT FROM`, direction-aware
    fallback) while the parameter binding stays as `Option<DomainEnum>`.
  - 83 macro tests pass.
@vindard vindard force-pushed the feat/nullable-column-attribute branch from daabfdf to 30c0cda Compare May 26, 2026 20:37
@vindard vindard changed the base branch from fix/desc-nulls-last-cursor-condition to main May 26, 2026 20:37
@vindard vindard merged commit ffa4b5f into main May 26, 2026
1 check passed
vindard added a commit to GaloyMoney/cala that referenced this pull request May 27, 2026
es-entity 0.10.37 contains the direction-aware NULL fallback in cursor
WHERE clauses for nullable sort columns (GaloyMoney/es-entity#137) and
the opt-in `nullable` column attribute for non-Option<T> Rust types
(GaloyMoney/es-entity#138).

The macro change in #137 alters the DESC SQL emitted for `Option<...>`
columns — for cala-ledger this affects `Account.external_id` and
`AccountSet.external_id` pagination. New fingerprints replace the old
ones in `cala-ledger/.sqlx/`; semantics are unchanged for non-NULL rows
and now also include NULL rows on page 2+ for DESC (previously dropped).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

3 participants