feat(macros): nullable column attribute for non-Option<T> Rust types#138
Merged
Conversation
jirijakes
approved these changes
May 26, 2026
thevaibhav-dixit
approved these changes
May 26, 2026
`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.
daabfdf to
30c0cda
Compare
3 tasks
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Column::is_optional()checks the Rust type syntactically — true only when the type literally begins withOption<. That misses a real shape: a domain type whose customsqlx::Encodeimpl maps one variant to SQL NULL (e.g. an enumFinite(Decimal) | Infinitethat encodesInfiniteasIsNull::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
nullableattribute on the column declaration: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 isOption<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 eitheris_optional()ornullable. DrivesORDER BY ... NULLS FIRST/LASTemission and the nullable-aware cursorWHEREform (see fix(macros): direction-aware NULL fallback in cursor condition for nullable sort columns #137).For a column declared
ty = "DomainEnum", nullable: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 asOption<T>).value as Option<DomainEnum>(else branch ofquery_arg_tokens). sqlx encodes the variant that maps toIsNull::Yesas NULL, the rest as their normal payload.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 cleanerty = "LoanToCollateralRatio", nullableand restore the customsqlx::Encodeimpls on the domain type.Test plan
list_by_fn_nullable_attribute_emits_nullable_aware_sql_for_non_option_typeverifies the emitted SQL matchesOption<T>'s (NULLS FIRST/LAST,IS NOT DISTINCT FROM, direction-aware fallback) while the parameter binding stays asOption<DomainEnum>.🤖 Generated with Claude Code