Skip to main content

.NET Nullable Is Not Load State: Preserving SQL NULL in E Expressions

· 4 min read
Philip Z
Architect

C# nullable reference types answer an important question: may this property have no value?

They do not answer a different data-access question: was this property loaded at all?

For a partially selected entity, string? Name == null can mean either SQL NULL or “the query never selected Name.” TeaQL's generated .NET E expressions model those as separate states and preserve the distinction through ADO.NET mapping.

Why Nullable Types Are Necessary but Insufficient

Suppose a task name is optional:

string? name = E.Task(task).Name().Eval();

If the query selected name and the database returned NULL, Eval() should return null. If the query selected only id, the same return value would hide an incomplete query.

The generated contract is:

StateMeaning.NET behavior
ValueSelected and non-nullEval() returns the value
NullSelected and DBNullEval() returns null
NotLoadedColumn absent from the rowthrows TeaQLNotLoadedException

HasValue also throws for NotLoaded. It cannot be used to turn a query error into ordinary absence.

The ADO.NET Boundary Matters

Many row mappers contain logic like this:

if (!reader.IsDBNull(index))
record[reader.GetName(index)] = ConvertValue(reader.GetValue(index));

That code destroys the distinction before the entity exists. A selected NULL column disappears from the record and becomes indistinguishable from an unselected column.

TeaQL now keeps the key and records an explicit null value:

record[reader.GetName(index)] = reader.IsDBNull(index)
? new Value.NullValue()
: FromDbValue(reader.GetValue(index));

The generated model then maps the record while retaining its keys:

Task task = Task.FromRecord(record);

if (!task.IsLoaded("Name"))
throw new Exception("Name should have been part of the projection");

string? name = E.Task(task).Name().Eval();

FromRecord assigns non-null values and marks every returned column as loaded, including Value.NullValue.

Typed Query APIs for E Evaluation

Generated requests expose raw records for aggregation and low-level work, and typed entity methods for load-aware evaluation:

var tasks = await Q.Tasks()
.WithNameIs("Hello")
.Comment("Read the task for display")
.Purpose("Render the task detail page")
.ExecuteEntitiesForListAsync(ctx, service);

var name = E.Task(tasks[0]).Name().Eval();

The corresponding single-result method is ExecuteEntityForOneAsync.

These APIs make the mapping choice explicit. A caller that wants dictionaries can keep using ExecuteForListAsync; a caller that wants generated E expressions can request load-aware models.

Fallback Is Not an Error Handler

The following code uses a valid business default only when Name was loaded and absent:

string label = E.Task(task).Name().OrElse("Unnamed");

For an unselected Name, OrElse propagates TeaQLNotLoadedException. The exception includes Root, AccessPath, BreakPoint, and SuggestedFix:

try
{
E.Task(Task.Refer(99)).Name().OrElse("Unnamed");
}
catch (TeaQLNotLoadedException error)
{
Console.WriteLine(error.AccessPath); // Name
Console.WriteLine(error.SuggestedFix); // SelectName(...)
}

This turns a silent default into an actionable test failure.

Real SQLite Verification

The generated .NET SQLite integration test creates and saves a task graph, updates a selected column to SQL NULL through ADO.NET, reloads it with a typed request, and verifies all three conditions:

typedLog.IsLoaded("Detail") == true
E.TaskExecutionLog(typedLog).Detail().Eval() == null

The same test also verifies schema creation, graph persistence, reconnect and query, update, optimistic version increment, and direct database reads.

At the generator level, the full regression finished with 109 tests, zero failures, and zero errors. Tests requiring unavailable connection variables were reported as conditional skips rather than successes.

Clear Failures Are an AI Feature

A coding agent faced with a late NullReferenceException must infer whether the problem is bad data, a nullable field, mapping code, or a missing selection.

A TeaQLNotLoadedException reduces the repair to a bounded task: inspect the generated request, add the exact selection identified by the diagnostic, and rerun the test. The test then permanently verifies that the business expression has the data it requires.

Nullable annotations describe the domain. Load state describes the query. A robust data runtime needs both.