Nullability
Every slot has a rule for what a NULL column does. The default follows the runtime type. Two attributes cover the common adjustments.
The default
Inside an object being built, a slot follows its runtime type:
- A reference type accepts
NULLand receivesnull. Nullable<T>acceptsNULLand receivesnull.- A non-nullable struct rejects
NULLand throws (NullValueAssignmentException).
public record Track(int Id, string Name, string? Composer);
// Id -> NULL throws, an int cannot be null
// Name -> NULL gives null
// Composer -> NULL gives null
The engine reads the runtime type, not the nullable-reference-type annotation. string and string? are the same to it, both accept NULL, so the ? on a reference type carries no meaning here. Only value types split. A plain struct rejects NULL, Nullable<T> accepts it.
The type you query for directly is stricter. At the root only Nullable<T> accepts NULL on its own. Query<int?> gives null, Query<string> throws. Accepting null at the root of a reference type is a result shape, MaybeNull<T>.
[NotNull], fail loudly
The runtime type of a reference slot cannot say "never null", which is why it defaults to accepting NULL. When your string Name really means it, say it with [NotNull] (the standard System.Diagnostics.CodeAnalysis attribute): NULL then throws instead of handing you null.
public record Track(int Id, [NotNull] string Name);
// a NULL Name is a bug, fail loudly
The mirror exists too: [MaybeNull] makes a slot accept NULL regardless of its type.
The runtime form, for a type you cannot annotate, is UpdateNullColHandler. Its simplest overload sets the rule by slot name:
TypeParsingInfo.GetOrAdd<Track>().UpdateNullColHandler("Name", NotNullHandle.Instance); // what [NotNull] declares
When the target is not just a name, the same method takes a visitor instead. It receives each slot and decides.
TypeParsingInfo.GetOrAdd<Track>().UpdateNullColHandler(slot =>
slot.Type == typeof(string)
? NotNullHandle.Instance // every string slot rejects NULL
: null); // null leaves the slot as is
[InvalidOnNull], collapse the object
[InvalidOnNull] on a slot means that if its value is NULL, the object is not built at all. The typical case is an optional joined object, where a missing match leaves its columns all NULL.
public record struct Package([InvalidOnNull] int TrackingId, double Weight) : IDbReadable;
public record Shipment(int Id, Package? Contents);
// TrackingId NULL -> Contents is null, not a Package full of zeroes
The runtime form is SetInvalidOnNull, the same simple-then-visitor pair as UpdateNullColHandler above. By slot name:
TypeParsingInfo.GetOrAdd<Package>().SetInvalidOnNull("TrackingId", true);
Or the visitor overload, returning null to leave a slot as is:
TypeParsingInfo.GetOrAdd<Package>().SetInvalidOnNull(slot =>
slot.Type == typeof(int) ? true : null);
A collapse abandons the current object and lands on the slot that holds it. What happens there depends on that slot:
- A nullable slot (a reference type or
Nullable<T>) receivesnull. - A non-nullable slot throws, the same
NullValueAssignmentExceptiona plainNULLgives it. Mark that slot[InvalidOnNull]too and it collapses in turn, carrying the abandonment up to its own parent. - At the top level, the collapse is a null result. A null-accepting shape takes it as empty,
MaybeNull<T>orOptionalNullable<T>for a reference type,T?for a struct. PlainT,Optional<T>, andOptionalStruct<T>throw. These are the same shapes that accept aNULLvalue, see result shapes.
public record struct Bottom([InvalidOnNull] int Key, string Name) : IDbReadable;
public record Middle(int Id, [InvalidOnNull] Bottom Bottom) : IDbReadable;
public record Top(int Id, Middle? Middle);
// Bottom.Key is NULL -> Bottom collapses -> Middle's [InvalidOnNull] Bottom collapses Middle -> Top.Middle (nullable) is null
Note the difference with result shapes: zero rows is a query-level outcome, a NULL column is a slot-level one.