7.9.19. SQL-18 — NULL Handling: Option<T> Everywhere
Nullability is type-driven. The struct field’s daslang type is
the single source of truth — there is no @sql_nullable
annotation. The same [sql_table] struct doubles as the row shape
and the schema.
Field type |
DDL emitted |
|---|---|
|
|
|
|
The [sql_table] macro inspects each field at compile time and
emits the matching DDL, INSERT/UPDATE binds, and SELECT readers.
7.9.19.1. DDL example
[sql_table(name="Users")]
struct User {
@sql_primary_key Id : int
Name : string
@safe_when_uninitialized Age : Option<int>
@safe_when_uninitialized Nick : Option<string>
@safe_when_uninitialized DeletedAt : Option<int64>
}
Generated DDL:
CREATE TABLE "Users"(
"Id" INTEGER PRIMARY KEY,
"Name" TEXT NOT NULL,
"Age" INTEGER,
"Nick" TEXT,
"DeletedAt" INTEGER
)
The @safe_when_uninitialized annotation on each Option<T>
field is required under strict_smart_pointers so the macro can
default-initialize a row in the row-builder. The Option’s internal
_value is already so-marked inside the template; the wrapping
struct field still needs its own annotation.
7.9.19.2. Round-trip — some(v) and none()
INSERT/UPDATE bind code branches per field type at compile time.
is_some(field) binds the underlying value; is_none() binds
sqlite3_bind_null. SELECT readers check
sqlite3_column_type == SQLITE_NULL and wrap accordingly.
db |> insert(User(
Id = 1, Name = "alice",
Age = some(30), Nick = none(type<string>), DeletedAt = none(type<int64>)))
let everyone <- db |> select_from(type<User>)
for (u in everyone) {
if (u.Age |> is_some) {
to_log(LOG_INFO, "{u.Name}: age {u.Age |> unwrap}\n")
} else {
to_log(LOG_INFO, "{u.Name}: age unknown\n")
}
}
7.9.19.3. NULL predicates in _where
Three Option methods translate to SQL null operators:
Source shape |
Emitted SQL |
|---|---|
|
|
|
|
|
|
is_some / is_none:
let known_age <- _sql(db |> select_from(type<User>)
|> _where(_.Age |> is_some))
// ... WHERE "Age" IS NOT NULL
let alive <- _sql(db |> select_from(type<User>)
|> _where(_.DeletedAt |> is_none))
// ... WHERE "DeletedAt" IS NULL
unwrap_or — COALESCE with a fallback:
let cutoff = 18
let adults_or_unknown <- _sql(db |> select_from(type<User>)
|> _where(_.Age |> unwrap_or(0) >= cutoff))
// ... WHERE COALESCE("Age", ?) >= ? binds: [0, 18]
The unwrap_or default (0 here) routes through the predicate
rewriter — both captured locals and literals are emitted as ?
bind parameters (the analyzer doesn’t bother distinguishing; keeping
the SQL parameterized either way is the safe-by-default behavior;
see SQL-07 — Anatomy of _sql).
7.9.19.4. Option<T> in projections
Full-row projection (no _select) preserves each field’s exact
type — Option<T> stays Option<T> in the result struct.
There is no implicit unwrap. Users see the same nullability
discipline at projection sites as at row sites.
7.9.19.5. Three-valued-logic gotcha
In SQL, NULL = NULL is NULL, never TRUE. NULL <> x
is also NULL. WHERE treats NULL-valued predicates as
false, so WHERE Col = NULL matches nothing. The Option API
steers users away from that footgun: use _.Col |> is_none()
(emits IS NULL) or _.Col |> unwrap_or(d) == x (emits
COALESCE then compare).
Direct _.Col == none() in a predicate is intentionally not
translated this chunk. A future revision may either lower it to
IS NULL automatically or raise a macro_error with a fix-it
pointing to |> is_none() — the leaning is toward the explicit
diagnostic so the user has to confront three-valued logic head-on.
7.9.19.6. _try_sql composes
The non-panicking _try_sql form composes with all of the above
— the Option methods are predicate-translation rules, orthogonal
to the outer success/failure wrapper:
let attempt <- _try_sql(db |> select_from(type<User>)
|> _where(_.Age |> is_some))
if (attempt |> is_ok) {
let rows <- attempt |> unwrap
// ...
}
See also
Full source: tutorials/sql/18-null_handling.das
Previous tutorial: SQL-17 — Subqueries
Next tutorial: SQL-19 — UPDATE