Skip to content

Add optional() / required() to ColumnDesc and Column - #37

Merged
emilk merged 3 commits into
release-0.6from
emilk/column-desc-optional
Aug 27, 2026
Merged

Add optional() / required() to ColumnDesc and Column#37
emilk merged 3 commits into
release-0.6from
emilk/column-desc-optional

Conversation

@emilk

@emilk emilk commented Aug 27, 2026

Copy link
Copy Markdown
Member

Related

What

optional() widens a column to nullable, required() narrows it back:

Self::COLUMN_CHUNK_KEY.optional().extract(&batch)?;   // reads the nulls
Self::COLUMN_CHUNK_KEY.optional().arrow_field();      // declared nullable
column.optional();                                    // Column<T>         -> Column<Option<T>>
column.try_required()?;                               // Column<Option<T>> -> Column<T>

A column can be declared non-nullable and still legitimately hold nulls on some code path (e.g. concatenating a batch that has the column with one that does not). Previously that needed a second descriptor restating the name and dropping the metadata.

Both directions are idempotent: LogicalType gains Optional and Required associated types, so .optional().optional() does not pile up Option<Option<T>>. They are bound to the same Typed — nullability lives in the validity bitmap, not in the downcast representation — so the conversions copy nothing and re-downcast nothing. Column::try_required is the only fallible one, erroring with UnexpectedNulls.

⚠️ Breaking for hand-written LogicalType impls: they must now state type Optional = Option<Self>; and type Required = Self;. Associated-type defaults are unstable, so there is no way to make this additive. The derive and the type macros need no change.

Testing

column_desc_optional_and_required and column_optional_and_required: metadata and name carry over, only field nullability flips; both directions idempotent; nulls rejected only by extract / try_required; empty and all-null columns as the boundary cases. Plus four doctests.

Compatibility

Additive for users of the derive and the built-in logical types. Breaking for hand-written LogicalType impls, as above.

emilk and others added 2 commits August 27, 2026 19:08
…mn tolerantly

A column can be declared non-nullable and still legitimately hold nulls
on some code path, e.g. after concatenating a batch that has the column
with one that does not. `optional()` gives a `ColumnDesc<Option<L>>`
that reads and declares such a column, carrying the record type, name,
and metadata over, so the name stays single-sourced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`LogicalType` gains two associated types: `Optional` (`Option<Self>`,
but `Self` for `Option<L>`) and `Required` (`Self`, but `L::Required`
for `Option<L>`). Both are bound to the same `Typed`, since nullability
lives in the validity bitmap and not in the downcast representation, so
converting between the two spellings is free.

`ColumnDesc::optional()` now returns `ColumnDesc<L::Optional>` rather
than nesting into `Option<Option<L>>`, and gains `required()`. `Column`
gets the same pair: `optional()` is infallible, `try_required()` errors
with `UnexpectedNulls` on a column that really does hold nulls. Both
carry the metadata over, and neither copies or re-downcasts.

Breaking for hand-written `LogicalType` impls, which must now state
`type Optional` and `type Required` (associated-type defaults are
unstable). The derive and the type macros need no change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@emilk emilk changed the title Add ColumnDesc::optional() for reading a declared-non-nullable column tolerantly Add optional() / required() to ColumnDesc and Column Aug 27, 2026
The methods are named after the Rust type the caller gets (`Option<L>`),
not after arrow's field flag — and a `nullable()` next to the existing
`NULLABLE` bool const would read like a getter for it. The aliases let a
rustdoc search in arrow's vocabulary find them anyway.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@emilk emilk left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Post-merge review, as part of reviewing the 0.6 stack for #30. Clippy and tests are clean at the tip of release-0.6. The core insight — Optional/Required share one Typed, so the conversion is a pure retype with nothing to re-validate — is what makes this a feature rather than a wrapper around try_new, and spelling the idempotence assertions as type annotations in the test is a nice trick.

Column::<Run<K, V>>::optional() builds a type #42 documents as unbuildable. Verified at the tip of release-0.6:

Column::<Run<i32, Utf8>>::try_from_values(["a", "a", "b"]).unwrap().optional().to_vec()
// -> [Some("a"), Some("a"), Some("b")]

Column<Option<Run<K, V>>> is reachable, and reads as all-Some forever because a RunArray has no top-level validity buffer for Option<L>::is_null to consult. Not unsound, and optional() being generic over LogicalType is by design — but #42's "unbuildable by any route" is wrong, and a user reaching for .optional() specifically to tolerate nulls gets silence instead of an error. A sentence in Run's docs is probably the whole fix.

TypedArray doesn't get the feature. into_optional / try_into_required already exist as pub(crate) and already do the whole job. TypedArray is pitched (since #23) as "the same array with the same value API, minus the metadata"; nullability isn't metadata, so this is the first place that promise doesn't hold. It's a rename away.

Minor: nothing stops a hand-written impl writing type Optional = Self; on a non-nullable type, silently making optional() a no-op. type Optional: LogicalType<Typed = Self::Typed, Required = Self::Required> (and the mirror) holds for every impl in the crate — the only question is whether the solver copes with the mutual reference.

— Claude

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.

1 participant