Java E Expressions: Catch Unloaded Data Before Production
Java applications have spent decades improving null handling, yet one dangerous ambiguity remains common in data-access code:
Does
nullmean the database value is NULL, or does it mean the query never loaded the property?
Those two states have completely different business meanings. Treating both as
null can make a test pass and let the wrong decision reach production.
TeaQL's generated Java E expressions now preserve three states: Value, Null, and NotLoaded. A missing preload becomes a structured exception at the point of use, while a genuine SQL NULL remains a legitimate null value.
The Bug That Ordinary Null Safety Cannot See
Consider a task query that selects only id, followed by business logic that
reads name:
Task partial = Task.refer(99L);
String displayName = E.task(partial).getName().orElse("Unnamed");
Returning "Unnamed" would look null-safe, but it would hide a query bug. The
application does not know that the name is absent; it never asked the database
for the name.
The generated E expression instead throws E.TeaQLNotLoadedException. Even the
fallback propagates the exception:
try {
E.task(partial).getName().orElse("Unnamed");
throw new AssertionError("NotLoaded was hidden");
} catch (E.TeaQLNotLoadedException error) {
assertEquals("name", error.getAccessPath());
assertEquals("name", error.getBreakPoint());
assertEquals("selectName(...)", error.getSuggestedFix());
}
This behavior is intentional. A default is appropriate for a loaded null, not for missing evidence.
The Three-State Contract
The generated entity tracks which properties were loaded. E expressions carry that state through scalar fields, to-one relations, and reverse lists.
| State | Meaning | Java behavior |
|---|---|---|
| Value | Selected and non-null | eval() returns the value |
| Null | Selected and SQL NULL | eval() returns null |
| NotLoaded | Not selected by the query | throws TeaQLNotLoadedException |
The distinction survives database mapping. TeaQL checks whether the result row contains a column, not whether its value is non-null. A selected SQL NULL still invokes the generated entity mapper and marks the property as loaded.
Task loaded = /* loaded from a SELECT that includes name */;
assertNull(E.task(loaded).getName().eval());
That is a subtle but essential rule for partial projections and relation-heavy domain models.
A Diagnostic Designed for Tests and Coding Agents
The exception includes machine-readable context:
TeaQLNotLoadedError:
root=Task(id=99)
access_path=name
break_point=name
suggested_fix=selectName(...)
For a deeper chain, the access path identifies the exact point where available
data ends. A developer can act on this immediately. An AI coding agent can do
the same without guessing method names or reconstructing the query from an
unrelated NullPointerException.
The resulting loop is short:
- Generate the typed model and query APIs.
- Run unit or integration tests.
- Let the E expression fail at the first unloaded access.
- Read the path and suggested preload.
- Update the generated query chain and rerun the test.
The important shift is temporal: the bug becomes a test failure instead of a production data-quality incident.
Why This Is More Than Another Optional Wrapper
Optional<T> can represent a value or absence. It cannot, by itself, explain
whether absence is legitimate or caused by an incomplete query. Lazy-loading
proxies can defer the question, but they introduce hidden I/O and can still fail
after a session has closed.
E expressions make the query contract explicit. They do not issue a surprise query. They either evaluate already-loaded data, return a legitimate null, or identify the missing preload.
Verification Evidence
This behavior was tested in the generated Java code and in the Java runtime:
- a reference entity fails on an unloaded scalar, including through fallback;
- PostgreSQL returns a genuine SQL NULL that evaluates as Null;
- the portable SQL runtime maps a selected SQLite NULL as loaded;
- the complete generator regression finished with 109 tests, zero failures, and zero errors; environment-dependent database cases remained conditional.
The PostgreSQL test ran against an isolated test container, and the portable mapping test used a real in-memory SQLite database.
An AI-Native Data Contract
The goal is not to make every missing value fatal. The goal is to distinguish business absence from missing information.
Once that distinction is part of generated code, tests become much more powerful. They verify not only that a method returns the expected value, but also that the query loaded enough evidence to make the decision. And when the contract is violated, both humans and AI agents receive a precise repair path.
